descry-codegraph 0.1.0__py3-none-any.whl

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.
descry/query.py ADDED
@@ -0,0 +1,2345 @@
1
+ import logging
2
+ import os
3
+ import math
4
+ import time
5
+ from collections import defaultdict
6
+ from functools import lru_cache
7
+ import re
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # --- Module-level Constants ---
12
+ # These provide consistent limits across all operations
13
+
14
+ # Depth and recursion limits
15
+ MAX_DEPTH = 3 # Maximum recursion depth for callees/flow
16
+ MAX_NODES_PER_OPERATION = 100 # Safety limit for recursive operations
17
+ MAX_CHILDREN_PER_LEVEL = 10 # Branching limit per level
18
+
19
+ # Token budgets and thresholds
20
+ DEFAULT_TOKEN_BUDGET = 2000 # Default token budget for expanded callees
21
+ CALLEE_INLINE_THRESHOLD = 150 # Inline callees smaller than this (was 100)
22
+ MAX_INLINE_THRESHOLD = 300 # Maximum allowed inline threshold
23
+ MAX_CALLERS_SHOWN = 15 # Maximum callers to show in context
24
+
25
+ # Timeout (milliseconds)
26
+ TIMEOUT_MS = 4000 # Wall-clock timeout for recursive operations
27
+
28
+
29
+ def _estimate_tokens(text: str) -> int:
30
+ """Estimate token count from text (rough approximation: 4 chars ≈ 1 token)."""
31
+ return len(text) // 4
32
+
33
+
34
+ # --- File Content LRU Cache ---
35
+ # Reduces I/O from ~150 to ~30 reads for deep context operations
36
+
37
+
38
+ @lru_cache(maxsize=128)
39
+ def _read_file_cached_inner(file_path: str, mtime_ns: int) -> tuple[str, ...]:
40
+ """Inner cache keyed on (path, mtime_ns) so edits invalidate the entry.
41
+
42
+ The mtime is read once by the outer wrapper and threaded through as a
43
+ cache key — callers get tuple-of-lines without worrying about staleness.
44
+ """
45
+ try:
46
+ with open(file_path, "r", encoding="utf-8") as f:
47
+ return tuple(f.readlines())
48
+ except Exception:
49
+ return ()
50
+
51
+
52
+ def _read_file_cached(file_path: str) -> tuple[str, ...]:
53
+ """Read file content with mtime-aware LRU caching.
54
+
55
+ Returns tuple of lines (tuple for hashability in cache). Stats the file
56
+ to capture mtime_ns and uses (path, mtime_ns) as the cache key, so an
57
+ edit on disk invalidates the cached content automatically.
58
+ """
59
+ try:
60
+ mtime_ns = os.stat(file_path).st_mtime_ns
61
+ except OSError:
62
+ return ()
63
+ return _read_file_cached_inner(file_path, mtime_ns)
64
+
65
+
66
+ def _clear_file_cache():
67
+ """Clear the file content cache (useful after indexing)."""
68
+ _read_file_cached_inner.cache_clear()
69
+
70
+
71
+ def _get_syntax_lang(file_path: str, lang_map: dict[str, str] | None = None) -> str:
72
+ """Determine syntax highlighting language from file extension."""
73
+ ext = os.path.splitext(file_path)[1].lower()
74
+ if lang_map:
75
+ return lang_map.get(ext, "")
76
+ default_lang_map = {
77
+ ".rs": "rust",
78
+ ".py": "python",
79
+ ".ts": "typescript",
80
+ ".tsx": "typescript",
81
+ ".js": "javascript",
82
+ ".jsx": "javascript",
83
+ ".svelte": "svelte",
84
+ ".proto": "protobuf",
85
+ ".json": "json",
86
+ ".toml": "toml",
87
+ ".yaml": "yaml",
88
+ ".yml": "yaml",
89
+ ".md": "markdown",
90
+ }
91
+ return default_lang_map.get(ext, "")
92
+
93
+
94
+ def _normalize_name(name: str) -> str:
95
+ """Normalize a name to snake_case for case-insensitive matching.
96
+
97
+ Handles:
98
+ - camelCase -> camel_case
99
+ - PascalCase -> pascal_case
100
+ - already_snake_case -> already_snake_case
101
+ - SCREAMING_CASE -> screaming_case
102
+
103
+ This allows searching for 'getClient' to find 'get_client'.
104
+ """
105
+ # Insert underscore before uppercase letters that follow lowercase
106
+ # e.g., getClient -> get_Client
107
+ s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
108
+ # Insert underscore before uppercase letters that precede lowercase (for acronyms)
109
+ # e.g., HTTPServer -> HTTP_Server -> http_server
110
+ s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
111
+ return s2.lower()
112
+
113
+
114
+ def _get_name_variants(name: str) -> list[str]:
115
+ """Get all naming convention variants of a name.
116
+
117
+ Returns a list of possible names in different conventions:
118
+ - Original
119
+ - snake_case normalized
120
+ - With common prefixes/suffixes
121
+
122
+ This allows 'getClient' to match 'get_client' and vice versa.
123
+ """
124
+ variants = [name]
125
+
126
+ # Add normalized snake_case version
127
+ normalized = _normalize_name(name)
128
+ if normalized != name.lower():
129
+ variants.append(normalized)
130
+
131
+ # Also try the lowercased original (for case-insensitive exact match)
132
+ if name.lower() != name:
133
+ variants.append(name.lower())
134
+
135
+ return list(set(variants)) # Deduplicate
136
+
137
+
138
+ def _clean_ref_name(ref_name: str, max_len: int = 60) -> str:
139
+ """Clean up an unresolved reference name for display.
140
+
141
+ - Takes only the first line (removes multi-line expressions)
142
+ - Extracts the meaningful part (function/method name)
143
+ - Truncates if too long
144
+ """
145
+ # Take first line only
146
+ first_line = ref_name.split("\n")[0].strip()
147
+
148
+ # If it's a qualified call like Type::method(args), extract Type::method
149
+ if "::" in first_line:
150
+ # Match Type::method or Type::Variant pattern
151
+ match = re.match(
152
+ r"^([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)+)", first_line
153
+ )
154
+ if match:
155
+ first_line = match.group(1)
156
+
157
+ # If it's a method chain like foo.bar or foo.bar().baz, extract the last method
158
+ elif "." in first_line:
159
+ # Split by dots and extract the last meaningful method name
160
+ # Handles both: foo.bar.method and foo.bar().method()
161
+ parts = first_line.split(".")
162
+ for part in reversed(parts):
163
+ # Extract method name (strip parentheses and args)
164
+ method_match = re.match(r"^([a-zA-Z_][a-zA-Z0-9_]*)", part)
165
+ if method_match:
166
+ first_line = method_match.group(1)
167
+ break
168
+
169
+ # Truncate if still too long
170
+ if len(first_line) > max_len:
171
+ first_line = first_line[: max_len - 3] + "..."
172
+
173
+ return first_line
174
+
175
+
176
+ class GraphQuerier:
177
+ def __init__(self, graph_file, config=None):
178
+ from descry._graph import load_graph_with_schema
179
+
180
+ self.data = load_graph_with_schema(graph_file)
181
+
182
+ self.nodes = {n["id"]: n for n in self.data["nodes"]}
183
+ self.edges = self.data["edges"]
184
+
185
+ self.outgoing = defaultdict(list)
186
+ self.incoming = defaultdict(list)
187
+
188
+ for edge in self.edges:
189
+ self.outgoing[edge["source"]].append(edge)
190
+ self.incoming[edge["target"]].append(edge)
191
+
192
+ # Config-driven limits (fall back to module-level constants)
193
+ self._max_depth = config.max_depth if config else MAX_DEPTH
194
+ self._max_nodes = config.max_nodes if config else MAX_NODES_PER_OPERATION
195
+ self._max_children_per_level = (
196
+ config.max_children_per_level if config else MAX_CHILDREN_PER_LEVEL
197
+ )
198
+ self._max_callers_shown = (
199
+ config.max_callers_shown if config else MAX_CALLERS_SHOWN
200
+ )
201
+ self._timeout_ms = config.query_timeout_ms if config else TIMEOUT_MS
202
+ self._test_path_patterns = config.test_path_patterns if config else None
203
+ self._test_file_suffixes = config.test_file_suffixes if config else None
204
+ self._syntax_lang_map = config.syntax_lang_map if config else None
205
+
206
+ # Build IDF cache for TF-IDF search
207
+ self._idf_cache = self._build_idf_cache()
208
+
209
+ # Lazy filter indices - built on first use
210
+ self._index_by_crate = None
211
+ self._index_by_lang = None
212
+ self._index_by_type = None
213
+ self._index_is_test = None # Test files and test functions
214
+ self._indices_built = False
215
+
216
+ # Memoization cache for recursive expansion
217
+ self._expansion_cache = {} # {(node_id, depth): (results, token_cost)}
218
+
219
+ def get_source_segment(self, file_path, start_line, end_line):
220
+ """Get source code segment using cached file content."""
221
+ try:
222
+ lines = _read_file_cached(file_path)
223
+ if not lines:
224
+ return "<Error: file not found or empty>"
225
+ # line numbers are 1-based
226
+ start = max(0, start_line - 1)
227
+ end = min(len(lines), end_line)
228
+ return "".join(lines[start:end])
229
+ except Exception as e:
230
+ return f"<Error reading file: {e}>"
231
+
232
+ def _ensure_filter_indices(self):
233
+ """Build filter indices lazily on first use.
234
+
235
+ Creates indices for:
236
+ - Crate/module filtering (e.g., 'backend', 'webapp')
237
+ - Language filtering (rust, typescript, python, svelte)
238
+ - Type filtering (Function, Class, Method, Constant, File)
239
+ - Test detection (test files and test functions)
240
+
241
+ Uses sets of node IDs for fast intersection operations.
242
+ """
243
+ if self._indices_built:
244
+ return
245
+
246
+ self._index_by_crate = defaultdict(set)
247
+ self._index_by_lang = defaultdict(set)
248
+ self._index_by_type = defaultdict(set)
249
+ self._index_is_test = set()
250
+
251
+ lang_map = {
252
+ ".rs": "rust",
253
+ ".ts": "typescript",
254
+ ".tsx": "typescript",
255
+ ".js": "javascript",
256
+ ".jsx": "javascript",
257
+ ".svelte": "svelte",
258
+ ".py": "python",
259
+ ".proto": "protobuf",
260
+ }
261
+
262
+ # Test detection patterns
263
+ test_path_patterns = self._test_path_patterns or (
264
+ "/tests/",
265
+ "/test/",
266
+ "/_test/",
267
+ "/spec/",
268
+ "/testing/",
269
+ "/fixtures/",
270
+ "/mocks/",
271
+ "/__tests__/",
272
+ )
273
+ test_file_suffixes = self._test_file_suffixes or (
274
+ "_test.rs",
275
+ ".test.ts",
276
+ ".spec.ts",
277
+ "_test.py",
278
+ ".test.js",
279
+ ".spec.js",
280
+ ".test.tsx",
281
+ ".spec.tsx",
282
+ )
283
+ test_function_pattern = re.compile(r"^(test_|it_|describe_|spec_)")
284
+
285
+ for node in self.data["nodes"]:
286
+ node_id = node["id"]
287
+ node_type = node.get("type", "")
288
+ node_name = node.get("metadata", {}).get("name", "")
289
+
290
+ # Index by type
291
+ self._index_by_type[node_type].add(node_id)
292
+
293
+ # Index by crate and language (extract from file path)
294
+ if node_id.startswith("FILE:"):
295
+ path = node_id.replace("FILE:", "").split("::")[0]
296
+
297
+ # Extract crate (first directory component)
298
+ parts = path.split("/")
299
+ if parts:
300
+ crate = parts[0]
301
+ self._index_by_crate[crate].add(node_id)
302
+
303
+ # Extract language from extension
304
+ for ext, lang in lang_map.items():
305
+ if path.endswith(ext):
306
+ self._index_by_lang[lang].add(node_id)
307
+ break
308
+
309
+ # Test detection: check path patterns and file suffixes
310
+ is_test_file = any(p in path for p in test_path_patterns) or any(
311
+ path.endswith(s) for s in test_file_suffixes
312
+ )
313
+ if is_test_file:
314
+ self._index_is_test.add(node_id)
315
+
316
+ # Test detection: check function/method names
317
+ if node_type in ("Function", "Method") and test_function_pattern.match(
318
+ node_name
319
+ ):
320
+ self._index_is_test.add(node_id)
321
+
322
+ self._indices_built = True
323
+ logger.debug(
324
+ f"Filter indices built: {len(self._index_by_crate)} crates, "
325
+ f"{len(self._index_by_lang)} langs, {len(self._index_by_type)} types, "
326
+ f"{len(self._index_is_test)} test nodes"
327
+ )
328
+
329
+ def get_smart_source(
330
+ self,
331
+ file_path: str,
332
+ start_line: int,
333
+ end_line: int,
334
+ token_count: int,
335
+ full: bool = False,
336
+ head_lines_only: int = None,
337
+ ) -> str:
338
+ """Get source code with smart truncation based on token count.
339
+
340
+ Args:
341
+ file_path: Path to the source file
342
+ start_line: Starting line number (1-based)
343
+ end_line: Ending line number (1-based)
344
+ token_count: Estimated token count for the function
345
+ full: If True, bypass truncation and return complete source
346
+ head_lines_only: If set, show only first N lines (for quick orientation)
347
+
348
+ Strategy (when full=False and head_lines_only=None):
349
+ - < 1000 tokens: Show full source (most functions fit here)
350
+ - 1000-1800 tokens: Show first 60 lines + last 15 lines (medium-large function)
351
+ - > 1800 tokens: Show first 50 lines + last 15 lines with clear truncation
352
+
353
+ This preserves the signature, docstring, key logic, and return statement.
354
+ Threshold of 1000 tokens (~100 lines) covers most middleware/interceptor patterns.
355
+ """
356
+ try:
357
+ all_lines = _read_file_cached(file_path)
358
+ if not all_lines:
359
+ return "<Error: file not found or empty>"
360
+
361
+ # line numbers are 1-based
362
+ start = max(0, start_line - 1)
363
+ end = min(len(all_lines), end_line)
364
+ source_lines = list(all_lines[start:end])
365
+ total_lines = len(source_lines)
366
+
367
+ # head_lines_only: show just the first N lines for quick orientation
368
+ if head_lines_only is not None and head_lines_only > 0:
369
+ if total_lines <= head_lines_only:
370
+ return "".join(source_lines)
371
+ head = source_lines[:head_lines_only]
372
+ remaining = total_lines - head_lines_only
373
+ return (
374
+ "".join(head)
375
+ + f"\n // ... ({remaining} more lines, use full=true to see all) ...\n"
376
+ )
377
+
378
+ # Bypass truncation when full=True
379
+ if full:
380
+ return "".join(source_lines)
381
+
382
+ # Most functions: show everything (raised from 800 to 1000)
383
+ if token_count < 1000 or total_lines <= 100:
384
+ return "".join(source_lines)
385
+
386
+ # Medium-large functions: show more context
387
+ if token_count < 1800:
388
+ head_lines = 60
389
+ tail_lines = 15
390
+ else:
391
+ # Very large functions: still show substantial context
392
+ head_lines = 50
393
+ tail_lines = 15
394
+
395
+ if total_lines <= head_lines + tail_lines + 5:
396
+ # Not worth truncating
397
+ return "".join(source_lines)
398
+
399
+ # Build truncated output
400
+ head = source_lines[:head_lines]
401
+ tail = source_lines[-tail_lines:]
402
+ omitted = total_lines - head_lines - tail_lines
403
+
404
+ return (
405
+ "".join(head)
406
+ + f"\n // ... ({omitted} lines omitted, use full=true to see complete source) ...\n\n"
407
+ + "".join(tail)
408
+ )
409
+
410
+ except Exception as e:
411
+ return f"<Error reading file: {e}>"
412
+
413
+ def get_context_prompt(
414
+ self,
415
+ node_id,
416
+ depth: int = 1,
417
+ max_tokens: int = None,
418
+ inline_threshold: int = None,
419
+ full: bool = False,
420
+ head_lines: int = None,
421
+ expand_callees: bool = False,
422
+ callee_budget: int = 2000,
423
+ brief: bool = False,
424
+ max_output_tokens: int = None,
425
+ ):
426
+ """Get comprehensive context for a symbol.
427
+
428
+ Args:
429
+ node_id: Full node ID to get context for
430
+ depth: Recursion depth for callee expansion (1-3, default: 1)
431
+ max_tokens: Token budget for expanded callees (default: DEFAULT_TOKEN_BUDGET)
432
+ inline_threshold: Token threshold for inlining callees (default: CALLEE_INLINE_THRESHOLD)
433
+ full: If True, show full source without truncation (default: False)
434
+ head_lines: If set, show only first N lines of source (for quick orientation)
435
+ expand_callees: If True, inline full source of direct callees (default: False)
436
+ callee_budget: Token budget for expanded callees (default: 2000)
437
+ brief: If True, return minimal output (~50 tokens): signature, location, counts only
438
+ max_output_tokens: Hard cap on total output tokens. Applies progressive truncation.
439
+ """
440
+ if max_tokens is None:
441
+ max_tokens = DEFAULT_TOKEN_BUDGET
442
+ if inline_threshold is None:
443
+ inline_threshold = CALLEE_INLINE_THRESHOLD
444
+ depth = min(max(depth, 1), self._max_depth) # Clamp to 1-3
445
+
446
+ # Resolve node ID with fuzzy matching
447
+ resolved_id, resolve_msg = self._resolve_node_id(node_id)
448
+ if resolved_id is None:
449
+ return f"ERROR: {resolve_msg}"
450
+
451
+ node = self.nodes.get(resolved_id)
452
+ if not node:
453
+ return f"ERROR: Node '{node_id}' not found in graph."
454
+
455
+ # Use resolved ID from here on
456
+ node_id = resolved_id
457
+ fuzzy_match_note = f"\n\n> **Note:** {resolve_msg}\n" if resolve_msg else ""
458
+
459
+ # 1. Identify File
460
+ file_path_rel = node["id"].split("::")[0].replace("FILE:", "")
461
+ if not os.path.exists(file_path_rel):
462
+ return f"ERROR: Source file '{file_path_rel}' not found on disk."
463
+
464
+ # Determine syntax highlighting language
465
+ source_lang = _get_syntax_lang(file_path_rel, self._syntax_lang_map)
466
+
467
+ # 2. Get Source Code with smart truncation (or full if requested)
468
+ meta = node["metadata"]
469
+ start = meta.get("lineno", 1)
470
+ end = meta.get("end_lineno")
471
+ token_count = meta.get("token_count", 0)
472
+
473
+ # BRIEF MODE: Return minimal output (~50 tokens) for quick verification
474
+ if brief:
475
+ signature = meta.get("signature", f"{meta.get('name', '?')}(...)")
476
+ docstring = meta.get("docstring", "")
477
+ doc_preview = ""
478
+ if docstring:
479
+ doc_lines = [
480
+ line.strip() for line in docstring.split("\n") if line.strip()
481
+ ]
482
+ if doc_lines:
483
+ doc_preview = doc_lines[0][:120]
484
+
485
+ # Count callers and callees
486
+ callers_count = len(self.get_callers(meta.get("name", "")))
487
+ callees_count = len(self.get_callees(node_id))
488
+
489
+ output = [
490
+ f"### Brief: `{node_id}`",
491
+ f"**Location:** {file_path_rel}:{start} | **Tokens:** {token_count} | **Callers:** {callers_count} | **Callees:** {callees_count}",
492
+ "",
493
+ f"```{source_lang}",
494
+ signature,
495
+ "```",
496
+ ]
497
+ if doc_preview:
498
+ output.append("")
499
+ output.append(doc_preview)
500
+ if fuzzy_match_note:
501
+ output.append(fuzzy_match_note)
502
+
503
+ return "\n".join(output)
504
+
505
+ # If end_lineno not set, estimate from token count or use generous default
506
+ if end is None:
507
+ if token_count > 0:
508
+ # Estimate ~10 tokens per line
509
+ estimated_lines = max(20, token_count // 10)
510
+ end = start + estimated_lines
511
+ else:
512
+ # Generous default for unknown sizes
513
+ end = start + 100
514
+
515
+ source_code = self.get_smart_source(
516
+ file_path_rel,
517
+ start,
518
+ end,
519
+ token_count,
520
+ full=full,
521
+ head_lines_only=head_lines,
522
+ )
523
+
524
+ # 2b. For Classes/Structs, include impl block methods
525
+ impl_methods = []
526
+ if node["type"] == "Class":
527
+ # Find all methods that belong to this struct (children in the graph)
528
+ for child_id, child_node in self.nodes.items():
529
+ if child_node["type"] == "Method" and child_id.startswith(
530
+ node_id + "::"
531
+ ):
532
+ child_meta = child_node["metadata"]
533
+ child_sig = child_meta.get("signature", child_meta.get("name", ""))
534
+ child_doc = child_meta.get("docstring", "").split("\n")[0][:80]
535
+ child_tokens = child_meta.get("token_count", 0)
536
+ impl_methods.append(
537
+ {
538
+ "name": child_meta.get("name", ""),
539
+ "signature": child_sig,
540
+ "docstring": child_doc,
541
+ "tokens": child_tokens,
542
+ "lineno": child_meta.get("lineno", 0),
543
+ "id": child_id,
544
+ }
545
+ )
546
+ # Sort by line number
547
+ impl_methods.sort(key=lambda m: m["lineno"])
548
+
549
+ # 3. Find Dependencies (Callees) with Smart Inlining - deduplicated
550
+ # Use recursive expansion if depth > 1
551
+ if depth > 1:
552
+ expanded = {node_id} # Prevent cycles
553
+ callee_info, _ = self._expand_callees_recursive(
554
+ node_id,
555
+ depth=depth,
556
+ budget=max_tokens,
557
+ expanded=expanded,
558
+ current_depth=1,
559
+ indent=0,
560
+ inline_threshold=inline_threshold,
561
+ )
562
+ else:
563
+ # Original single-level expansion
564
+ callees = self.get_callees(node_id)
565
+ callee_info = []
566
+
567
+ for callee_id in callees:
568
+ # Check if we have the node (resolved)
569
+ callee_node = self.nodes.get(callee_id)
570
+
571
+ if callee_node:
572
+ # Resolve file path for inlining
573
+ c_file = callee_node["id"].split("::")[0].replace("FILE:", "")
574
+ c_meta = callee_node["metadata"]
575
+ c_tokens = c_meta.get("token_count", 999)
576
+ c_lang = _get_syntax_lang(c_file, self._syntax_lang_map)
577
+
578
+ # Header info
579
+ sig = c_meta.get("signature", f"{c_meta.get('name')}(...)")
580
+
581
+ # INLINING LOGIC: If small (< threshold tokens), show code.
582
+ if c_tokens < inline_threshold and os.path.exists(c_file):
583
+ c_start = c_meta.get("lineno", 1)
584
+ c_end = c_meta.get("end_lineno", c_start)
585
+ c_code = self.get_source_segment(c_file, c_start, c_end)
586
+ # Indent code content for proper markdown list nesting
587
+ c_code_indented = "\n".join(
588
+ " " + line for line in c_code.split("\n")
589
+ )
590
+ callee_info.append(
591
+ f"- **{c_meta.get('name')}** (Inlined, {c_tokens} toks):\n ```{c_lang}\n{c_code_indented}\n ```"
592
+ )
593
+ else:
594
+ # Summary mode
595
+ doc = c_meta.get("docstring", "").split("\n")[0]
596
+ callee_info.append(f"- `{sig}`\n > {doc}")
597
+ else:
598
+ # Unresolved REF - clean up for display
599
+ raw_name = callee_id.replace("REF:", "")
600
+ clean_name = _clean_ref_name(raw_name)
601
+ callee_info.append(f"- `{clean_name}` (External/Unresolved)")
602
+
603
+ # 4. Find Usage Examples (Callers) - grouped by file
604
+ usage_examples = self._get_usage_examples(node_id, limit=4)
605
+
606
+ # 4b. Get callers summary for explicit caller list
607
+ callers_summary = self._get_callers_summary(node_id)
608
+
609
+ # 5. Find Related Tests
610
+ related_tests = self._find_related_tests(file_path_rel, meta.get("name"))
611
+
612
+ # 5b. Find Related Configuration nodes in same file
613
+ file_id = f"FILE:{file_path_rel}"
614
+ related_configs = self._find_related_configs(file_id, node_id)
615
+
616
+ # 6. Format Output
617
+ output = []
618
+ output.append(f"### Context for `{node['id']}`")
619
+ if fuzzy_match_note:
620
+ output.append(fuzzy_match_note)
621
+ output.append(
622
+ f"**Tokens:** {meta.get('token_count', '?')} | **In-Degree:** {meta.get('in_degree', 0)}\n"
623
+ )
624
+ output.append("#### Source Code:")
625
+ output.append(f"```{source_lang}\n{source_code}\n```\n")
626
+
627
+ # For Classes/Structs, show impl methods
628
+ if impl_methods:
629
+ output.append(f"#### Implementation Methods ({len(impl_methods)} methods):")
630
+ for method in impl_methods:
631
+ sig = method["signature"]
632
+ doc = method["docstring"]
633
+ tokens = method["tokens"]
634
+ # Show signature and brief doc
635
+ output.append(f"- `{sig}` ({tokens} toks)")
636
+ if doc:
637
+ output.append(f" > {doc}")
638
+ output.append("")
639
+
640
+ if usage_examples:
641
+ output.append("#### Usage Examples:")
642
+ # Group examples by file for better readability
643
+ by_file = defaultdict(list)
644
+ for ex in usage_examples:
645
+ by_file[ex["file"]].append(ex)
646
+
647
+ for file_path, examples in by_file.items():
648
+ ex_lang = _get_syntax_lang(file_path, self._syntax_lang_map)
649
+ output.append(f"\n**From `{file_path}`:**")
650
+ for ex in examples:
651
+ output.append(f"Line {ex['lineno']}:")
652
+ output.append(f"```{ex_lang}\n{ex['code']}\n```")
653
+ output.append("")
654
+
655
+ # Add explicit callers section
656
+ if callers_summary:
657
+ total_callers = len(self.get_callers(meta.get("name", "")))
658
+ output.append(f"#### Callers ({len(callers_summary)} shown):")
659
+ for caller in callers_summary:
660
+ loc = (
661
+ f"{caller['file']}:{caller['lineno']}"
662
+ if caller["lineno"]
663
+ else caller["file"]
664
+ )
665
+ output.append(
666
+ f"- `{caller['name']}` in {loc} ({caller['tokens']} toks)"
667
+ )
668
+ if total_callers > len(callers_summary):
669
+ output.append(
670
+ f" *({total_callers - len(callers_summary)} more callers)*"
671
+ )
672
+ output.append("")
673
+
674
+ if callee_info:
675
+ output.append("#### Dependencies (Callees):")
676
+ output.append("\n".join(callee_info))
677
+ output.append("")
678
+
679
+ # Expanded callees - inline full source of direct callees
680
+ if expand_callees:
681
+ expanded_callees = self._expand_callees_full(node_id, callee_budget)
682
+ if expanded_callees:
683
+ output.append("#### Expanded Callees (Full Source):")
684
+ for exp in expanded_callees:
685
+ output.append(
686
+ f"\n**{exp['name']}** ({exp['file']}:{exp['lineno']}, {exp['tokens']} toks):"
687
+ )
688
+ output.append(f"```{exp['lang']}\n{exp['source']}\n```")
689
+ output.append("")
690
+
691
+ if related_tests:
692
+ output.append("#### Related Tests:")
693
+ for t in related_tests:
694
+ output.append(f"- `{t}`")
695
+ output.append("")
696
+
697
+ if related_configs:
698
+ output.append("#### Related Configuration (same file):")
699
+ for cfg in related_configs:
700
+ cfg_type = f" [{cfg['config_type']}]" if cfg["config_type"] else ""
701
+ output.append(f"- L{cfg['lineno']}: `{cfg['signature']}`{cfg_type}")
702
+ output.append(f" Node: `{cfg['id']}`")
703
+ output.append("")
704
+
705
+ # Apply max_output_tokens progressive truncation if specified
706
+ result = "\n".join(output)
707
+ if (
708
+ max_output_tokens is not None
709
+ and _estimate_tokens(result) > max_output_tokens
710
+ ):
711
+ result = self._truncate_output_progressively(
712
+ result,
713
+ max_output_tokens,
714
+ source_code,
715
+ source_lang,
716
+ file_path_rel,
717
+ start,
718
+ )
719
+
720
+ return result
721
+
722
+ def _find_related_tests(self, source_file, symbol_name):
723
+ """
724
+ Heuristic to find test files/functions related to the source.
725
+ """
726
+ filename, ext = os.path.splitext(os.path.basename(source_file))
727
+ base_name = filename
728
+
729
+ possible_test_files = [
730
+ f"test_{base_name}{ext}",
731
+ f"{base_name}_test{ext}",
732
+ f"{base_name}.test{ext}", # TS
733
+ f"{base_name}.spec{ext}", # TS
734
+ ]
735
+
736
+ found_tests = []
737
+
738
+ # 1. Scan all File nodes to find matches
739
+ for nid, node in self.nodes.items():
740
+ if node["type"] == "File":
741
+ fpath = node.get("metadata", {}).get("path", "")
742
+ fbase = os.path.basename(fpath)
743
+ if fbase in possible_test_files:
744
+ found_tests.append(fpath)
745
+
746
+ # 2. Look for test functions
747
+ # Try both exact name and snake_case conversion
748
+ candidates = []
749
+ if symbol_name:
750
+ candidates.append(f"test_{symbol_name}")
751
+ # CamelToSnake
752
+ snake = re.sub(r"(?<!^)(?=[A-Z])", "_", symbol_name).lower()
753
+ candidates.append(f"test_{snake}")
754
+
755
+ if candidates:
756
+ for nid, node in self.nodes.items():
757
+ if node["type"] in ("Function", "Method"):
758
+ name = node["metadata"]["name"]
759
+ # Check exact match or verify it starts with test_ and contains symbol
760
+ if name in candidates:
761
+ found_tests.append(f"{node['id']} (Direct match)")
762
+ elif (
763
+ name.startswith("test_")
764
+ and snake in name
765
+ and source_file in node["id"]
766
+ ):
767
+ # Heuristic: Test in same file containing the snake_case symbol name
768
+ found_tests.append(f"{node['id']} (In-file match)")
769
+
770
+ return found_tests[:5] # Limit results
771
+
772
+ def _find_related_configs(self, file_id: str, exclude_node_id: str) -> list[dict]:
773
+ """Find Configuration nodes in the same file (interceptors, middleware, event handlers).
774
+
775
+ This helps users understand the full setup when looking at related functions.
776
+ For example, when viewing setAuthTokenGetter, show the request interceptor that uses it.
777
+
778
+ Args:
779
+ file_id: The file ID (e.g., "FILE:webapp/src/lib/api/client.ts")
780
+ exclude_node_id: The current node to exclude from results
781
+
782
+ Returns:
783
+ List of dicts with config node info (name, signature, lineno, id)
784
+ """
785
+ configs = []
786
+
787
+ # Find DEFINES edges from this file to Configuration nodes
788
+ for edge in self.edges:
789
+ if edge["source"] == file_id and edge["relation"] == "DEFINES":
790
+ target_id = edge["target"]
791
+ if target_id == exclude_node_id:
792
+ continue
793
+
794
+ target_node = self.nodes.get(target_id)
795
+ if target_node and target_node.get("type") == "Configuration":
796
+ meta = target_node.get("metadata", {})
797
+ configs.append(
798
+ {
799
+ "name": meta.get("name", "?"),
800
+ "signature": meta.get("signature", "?"),
801
+ "lineno": meta.get("lineno", 0),
802
+ "id": target_id,
803
+ "config_type": meta.get("config_type", ""),
804
+ }
805
+ )
806
+
807
+ # Sort by line number
808
+ configs.sort(key=lambda c: c["lineno"])
809
+ return configs[:5] # Limit to 5 config nodes
810
+
811
+ def _truncate_output_progressively(
812
+ self,
813
+ result: str,
814
+ max_tokens: int,
815
+ source_code: str,
816
+ source_lang: str,
817
+ file_path: str,
818
+ start_line: int,
819
+ ) -> str:
820
+ """Apply progressive truncation to fit within token budget.
821
+
822
+ Truncation priority (remove in order):
823
+ 1. Expanded Callees (Full Source) section
824
+ 2. Usage Examples - reduce to 2
825
+ 3. Related Tests section
826
+ 4. Related Configuration section
827
+ 5. Callers - reduce to 5
828
+ 6. Dependencies (Callees) - reduce to 5
829
+ 7. Source code - apply head_lines truncation
830
+
831
+ Args:
832
+ result: Full output text
833
+ max_tokens: Target token budget
834
+ source_code: Original source code for fallback
835
+ source_lang: Language for syntax highlighting
836
+ file_path: File path for context
837
+ start_line: Starting line number
838
+ """
839
+ import re
840
+
841
+ current_tokens = _estimate_tokens(result)
842
+
843
+ # Priority 1: Remove Expanded Callees section
844
+ if current_tokens > max_tokens:
845
+ result = re.sub(
846
+ r"#### Expanded Callees \(Full Source\):.*?(?=####|\Z)",
847
+ "",
848
+ result,
849
+ flags=re.DOTALL,
850
+ )
851
+ result = re.sub(r"\n{3,}", "\n\n", result)
852
+ current_tokens = _estimate_tokens(result)
853
+
854
+ # Priority 2: Reduce Usage Examples to 2
855
+ if current_tokens > max_tokens:
856
+ # Find and truncate usage examples section
857
+ match = re.search(
858
+ r"(#### Usage Examples:.*?)(?=####|\Z)", result, re.DOTALL
859
+ )
860
+ if match:
861
+ examples_section = match.group(1)
862
+ # Keep header and first 2 code blocks
863
+ code_blocks = list(
864
+ re.finditer(r"```.*?```", examples_section, re.DOTALL)
865
+ )
866
+ if len(code_blocks) > 2:
867
+ truncated_examples = examples_section[: code_blocks[1].end()]
868
+ truncated_examples += (
869
+ f"\n\n*({len(code_blocks) - 2} more examples omitted)*\n\n"
870
+ )
871
+ result = (
872
+ result[: match.start()]
873
+ + truncated_examples
874
+ + result[match.end() :]
875
+ )
876
+ current_tokens = _estimate_tokens(result)
877
+
878
+ # Priority 3: Remove Related Tests section
879
+ if current_tokens > max_tokens:
880
+ result = re.sub(
881
+ r"#### Related Tests:.*?(?=####|\Z)", "", result, flags=re.DOTALL
882
+ )
883
+ result = re.sub(r"\n{3,}", "\n\n", result)
884
+ current_tokens = _estimate_tokens(result)
885
+
886
+ # Priority 4: Remove Related Configuration section
887
+ if current_tokens > max_tokens:
888
+ result = re.sub(
889
+ r"#### Related Configuration.*?(?=####|\Z)", "", result, flags=re.DOTALL
890
+ )
891
+ result = re.sub(r"\n{3,}", "\n\n", result)
892
+ current_tokens = _estimate_tokens(result)
893
+
894
+ # Priority 5: Reduce Callers to 5
895
+ if current_tokens > max_tokens:
896
+ match = re.search(
897
+ r"(#### Callers \(\d+ shown\):)(.*?)(?=####|\Z)", result, re.DOTALL
898
+ )
899
+ if match:
900
+ callers_content = match.group(2)
901
+ lines = callers_content.strip().split("\n")
902
+ caller_lines = [line for line in lines if line.startswith("- `")]
903
+ if len(caller_lines) > 5:
904
+ # Keep first 5 callers
905
+ new_content = "\n".join(caller_lines[:5])
906
+ new_content += (
907
+ f"\n *({len(caller_lines) - 5} more callers omitted)*\n"
908
+ )
909
+ result = (
910
+ result[: match.start()]
911
+ + match.group(1)
912
+ + "\n"
913
+ + new_content
914
+ + result[match.end() :]
915
+ )
916
+ current_tokens = _estimate_tokens(result)
917
+
918
+ # Priority 6: Reduce Dependencies to 5
919
+ if current_tokens > max_tokens:
920
+ match = re.search(
921
+ r"(#### Dependencies \(Callees\):)(.*?)(?=####|\Z)", result, re.DOTALL
922
+ )
923
+ if match:
924
+ deps_content = match.group(2)
925
+ lines = deps_content.strip().split("\n")
926
+ dep_lines = [line for line in lines if line.startswith("- ")]
927
+ if len(dep_lines) > 5:
928
+ new_content = "\n".join(dep_lines[:5])
929
+ new_content += (
930
+ f"\n*({len(dep_lines) - 5} more dependencies omitted)*\n"
931
+ )
932
+ result = (
933
+ result[: match.start()]
934
+ + match.group(1)
935
+ + "\n"
936
+ + new_content
937
+ + result[match.end() :]
938
+ )
939
+ current_tokens = _estimate_tokens(result)
940
+
941
+ # Priority 7: Truncate source code (last resort)
942
+ if current_tokens > max_tokens:
943
+ # Apply aggressive head_lines truncation
944
+ match = re.search(
945
+ r"(#### Source Code:\n```" + source_lang + r"\n)(.*?)(```)",
946
+ result,
947
+ re.DOTALL,
948
+ )
949
+ if match:
950
+ source = match.group(2)
951
+ lines = source.split("\n")
952
+ if len(lines) > 30:
953
+ truncated = "\n".join(lines[:30])
954
+ truncated += f"\n// ... ({len(lines) - 30} lines omitted, max_output_tokens limit) ...\n"
955
+ result = (
956
+ result[: match.start()]
957
+ + match.group(1)
958
+ + truncated
959
+ + match.group(3)
960
+ + result[match.end() :]
961
+ )
962
+
963
+ # Add truncation note if we applied truncation
964
+ if current_tokens > max_tokens:
965
+ result += f"\n\n*[Output truncated to ~{max_tokens} tokens]*"
966
+
967
+ return result
968
+
969
+ def _expand_callees_full(self, node_id: str, budget: int = 2000) -> list[dict]:
970
+ """Expand direct callees with their full source code.
971
+
972
+ This inlines the complete implementation of functions called by node_id,
973
+ allowing users to understand the full flow without separate queries.
974
+
975
+ Args:
976
+ node_id: The node ID to find callees for
977
+ budget: Maximum total tokens to include (default: 2000)
978
+
979
+ Returns:
980
+ List of dicts with expanded callee info (name, file, lineno, tokens, lang, source)
981
+ """
982
+ callees = self.get_callees(node_id)
983
+ expanded = []
984
+ budget_remaining = budget
985
+
986
+ for callee_id in callees:
987
+ # Skip unresolved REFs
988
+ if callee_id.startswith("REF:"):
989
+ continue
990
+
991
+ callee_node = self.nodes.get(callee_id)
992
+ if not callee_node:
993
+ continue
994
+
995
+ meta = callee_node.get("metadata", {})
996
+ tokens = meta.get("token_count", 0)
997
+
998
+ # Skip if over budget or no token info
999
+ if tokens <= 0 or tokens > budget_remaining:
1000
+ continue
1001
+
1002
+ # Get file path and check it exists
1003
+ file_path = callee_id.split("::")[0].replace("FILE:", "")
1004
+ if not os.path.exists(file_path):
1005
+ continue
1006
+
1007
+ # Get source code
1008
+ start_line = meta.get("lineno", 1)
1009
+ end_line = meta.get("end_lineno", start_line + 50)
1010
+ source = self.get_source_segment(file_path, start_line, end_line)
1011
+
1012
+ if source:
1013
+ expanded.append(
1014
+ {
1015
+ "name": meta.get("name", "?"),
1016
+ "file": file_path,
1017
+ "lineno": start_line,
1018
+ "tokens": tokens,
1019
+ "lang": _get_syntax_lang(file_path, self._syntax_lang_map),
1020
+ "source": source.rstrip(),
1021
+ }
1022
+ )
1023
+ budget_remaining -= tokens
1024
+
1025
+ # Stop if budget exhausted
1026
+ if budget_remaining <= 0:
1027
+ break
1028
+
1029
+ return expanded
1030
+
1031
+ def _find_similar_nodes(self, query: str, limit: int = 5) -> list[dict]:
1032
+ """Find nodes similar to the given query when exact match fails.
1033
+
1034
+ Handles partial matches for:
1035
+ - Symbol name only (e.g., "authenticate" finds "FILE:auth.rs::authenticate")
1036
+ - Class::method format (e.g., "JwtAuth::from_request_parts")
1037
+ - Partial paths (e.g., "auth.rs::validate_token")
1038
+ - Naming convention variants (camelCase vs snake_case)
1039
+
1040
+ Returns list of candidate nodes with match quality scores.
1041
+ """
1042
+ # D.3: skip when query is empty/whitespace (would "match" all nodes).
1043
+ if not query or not query.strip():
1044
+ return []
1045
+ candidates = []
1046
+
1047
+ # Extract the symbol part from the query (last component after ::)
1048
+ query_parts = query.replace("FILE:", "").split("::")
1049
+ query_symbol = query_parts[-1] if query_parts else query
1050
+ query_normalized = _normalize_name(query_symbol)
1051
+
1052
+ # Also check for Class::method pattern
1053
+ query_class = query_parts[-2] if len(query_parts) >= 2 else None
1054
+ query_class_normalized = _normalize_name(query_class) if query_class else None
1055
+
1056
+ for node_id, node in self.nodes.items():
1057
+ if node["type"] == "File":
1058
+ continue # Skip file nodes
1059
+
1060
+ meta = node.get("metadata", {})
1061
+ node_name = meta.get("name", "")
1062
+ node_name_normalized = _normalize_name(node_name)
1063
+
1064
+ # Extract class/struct name from node_id
1065
+ id_parts = node_id.replace("FILE:", "").split("::")
1066
+ node_class = (
1067
+ id_parts[-2]
1068
+ if len(id_parts) >= 2 and node["type"] == "Method"
1069
+ else None
1070
+ )
1071
+
1072
+ score = 0
1073
+
1074
+ # Exact symbol name match (highest priority)
1075
+ if node_name == query_symbol:
1076
+ score += 100
1077
+ elif node_name_normalized == query_normalized:
1078
+ score += 80 # Naming convention variant
1079
+
1080
+ # Partial symbol match (D.3: require >= 2 chars to avoid
1081
+ # `"" in anything` being always True)
1082
+ elif len(query_normalized) >= 2 and (
1083
+ query_normalized in node_name_normalized
1084
+ or node_name_normalized in query_normalized
1085
+ ):
1086
+ score += 40
1087
+
1088
+ # Class::method match
1089
+ if query_class and node_class:
1090
+ node_class_normalized = _normalize_name(node_class)
1091
+ if node_class == query_class:
1092
+ score += 50
1093
+ elif node_class_normalized == query_class_normalized:
1094
+ score += 40
1095
+
1096
+ # Path match (if query contains path components)
1097
+ if len(query_parts) > 1:
1098
+ node_path = node_id.replace("FILE:", "")
1099
+ if query.replace("FILE:", "") in node_path:
1100
+ score += 30
1101
+
1102
+ if score > 0:
1103
+ candidates.append(
1104
+ {
1105
+ "id": node_id,
1106
+ "name": node_name,
1107
+ "type": node["type"],
1108
+ "score": score,
1109
+ "file": id_parts[0] if id_parts else "",
1110
+ "lineno": meta.get("lineno", 0),
1111
+ }
1112
+ )
1113
+
1114
+ # Sort by score descending, then by name
1115
+ candidates.sort(key=lambda x: (-x["score"], x["name"]))
1116
+ return candidates[:limit]
1117
+
1118
+ def _resolve_node_id(self, query: str) -> tuple[str | None, str | None]:
1119
+ """Resolve a query to an exact node ID, with fuzzy matching fallback.
1120
+
1121
+ Returns:
1122
+ (node_id, message) - node_id if found, message for user feedback
1123
+ """
1124
+ # Try exact match first
1125
+ if query in self.nodes:
1126
+ return query, None
1127
+
1128
+ # Try with FILE: prefix if not present
1129
+ if not query.startswith("FILE:") and f"FILE:{query}" in self.nodes:
1130
+ return f"FILE:{query}", None
1131
+
1132
+ # Fuzzy matching fallback
1133
+ similar = self._find_similar_nodes(query, limit=5)
1134
+ if not similar:
1135
+ return None, f"Node '{query}' not found and no similar matches."
1136
+
1137
+ # Auto-select if:
1138
+ # - Single match, OR
1139
+ # - High-confidence match (score >= 80 for naming variant, >= 100 for exact) that's clearly ahead
1140
+ best = similar[0]
1141
+ second_score = similar[1]["score"] if len(similar) > 1 else 0
1142
+
1143
+ if len(similar) == 1 or (
1144
+ best["score"] >= 80 and best["score"] > second_score + 20
1145
+ ):
1146
+ note = f"(Matched '{query}' → `{best['id']}`)"
1147
+ return best["id"], note
1148
+
1149
+ # Multiple candidates - return suggestions
1150
+ suggestions = []
1151
+ for s in similar[:5]:
1152
+ suggestions.append(
1153
+ f" - `{s['id']}` ({s['type']}, {s['file']}:{s['lineno']})"
1154
+ )
1155
+
1156
+ msg = f"Node '{query}' not found. Did you mean:\n" + "\n".join(suggestions)
1157
+ return None, msg
1158
+
1159
+ def _get_usage_examples(self, target_node_id, limit=2):
1160
+ """
1161
+ Finds places where target_node_id is called and extracts code snippets.
1162
+ """
1163
+ target_node = self.nodes.get(target_node_id)
1164
+ if not target_node:
1165
+ return []
1166
+
1167
+ target_name = target_node["metadata"]["name"]
1168
+ examples = []
1169
+
1170
+ # We need to find edges that target REF:target_name or REF:parent.target_name
1171
+ # The graph stores targets as "REF:..."
1172
+ # We will scan edges.
1173
+
1174
+ candidates = []
1175
+
1176
+ # Optimization: Pre-filter edges by relation
1177
+ # But we need specific line numbers from the edges now.
1178
+
1179
+ for edge in self.edges:
1180
+ if edge["relation"] == "CALLS":
1181
+ t = edge["target"].replace("REF:", "")
1182
+ # Flexible match: exact or attribute suffix
1183
+ if t == target_name or t.endswith(f".{target_name}"):
1184
+ candidates.append(edge)
1185
+
1186
+ # Sort candidates to prefer examples/tests if possible, or just by source path
1187
+ # Heuristic: put 'examples' or 'test' paths first
1188
+ def score_candidate(edge):
1189
+ src = edge["source"]
1190
+ if "example" in src:
1191
+ return 0
1192
+ if "test" in src:
1193
+ return 1
1194
+ return 2
1195
+
1196
+ candidates.sort(key=score_candidate)
1197
+
1198
+ for edge in candidates[:limit]:
1199
+ source_id = edge["source"]
1200
+ # Source ID is like FILE:path::Func
1201
+ file_path = source_id.split("::")[0].replace("FILE:", "")
1202
+ lineno = edge.get("metadata", {}).get("lineno")
1203
+
1204
+ if lineno and os.path.exists(file_path):
1205
+ # Get context around the call (e.g. -1 to +1 lines)
1206
+ code = self.get_source_segment(file_path, lineno - 1, lineno + 2)
1207
+ examples.append(
1208
+ {"file": file_path, "lineno": lineno, "code": code.strip()}
1209
+ )
1210
+
1211
+ return examples
1212
+
1213
+ def _get_callers_summary(self, target_node_id, limit=None):
1214
+ """Get summary of callers: name, file, lineno, tokens.
1215
+
1216
+ Args:
1217
+ target_node_id: The node ID to find callers for
1218
+ limit: Maximum callers to return (defaults to MAX_CALLERS_SHOWN)
1219
+
1220
+ Returns:
1221
+ List of dicts with caller info, sorted by token count (simpler first)
1222
+ """
1223
+ if limit is None:
1224
+ limit = self._max_callers_shown
1225
+
1226
+ target_node = self.nodes.get(target_node_id)
1227
+ if not target_node:
1228
+ return []
1229
+
1230
+ target_name = target_node.get("metadata", {}).get("name")
1231
+ if not target_name:
1232
+ return []
1233
+
1234
+ # Use the optimized get_callers method
1235
+ caller_ids = self.get_callers(target_name)
1236
+
1237
+ callers = []
1238
+ for caller_id in caller_ids:
1239
+ caller_node = self.nodes.get(caller_id)
1240
+ if caller_node:
1241
+ meta = caller_node.get("metadata", {})
1242
+ file_path = caller_id.split("::")[0].replace("FILE:", "")
1243
+ callers.append(
1244
+ {
1245
+ "id": caller_id,
1246
+ "name": meta.get("name", "?"),
1247
+ "file": file_path,
1248
+ "lineno": meta.get("lineno", 0),
1249
+ "tokens": meta.get("token_count", 0),
1250
+ "type": caller_node.get("type", "?"),
1251
+ }
1252
+ )
1253
+
1254
+ # Sort by tokens (simpler callers first), then by name for consistency
1255
+ callers.sort(key=lambda c: (c["tokens"], c["name"]))
1256
+ return callers[:limit]
1257
+
1258
+ def _expand_callees_recursive(
1259
+ self,
1260
+ node_id: str,
1261
+ depth: int,
1262
+ budget: int,
1263
+ expanded: set,
1264
+ current_depth: int = 1,
1265
+ indent: int = 0,
1266
+ start_time: float = None,
1267
+ inline_threshold: int = None,
1268
+ ) -> tuple[list[str], int]:
1269
+ """Recursively expand callees up to depth limit with token budget.
1270
+
1271
+ Args:
1272
+ node_id: The node to expand callees for
1273
+ depth: Maximum recursion depth (1-3)
1274
+ budget: Remaining token budget
1275
+ expanded: Set of already expanded node IDs (cycle prevention)
1276
+ current_depth: Current recursion level
1277
+ indent: Current indentation level
1278
+ start_time: Start time for timeout checking
1279
+ inline_threshold: Token threshold for inlining code
1280
+
1281
+ Returns:
1282
+ Tuple of (formatted lines list, remaining budget)
1283
+ """
1284
+ if start_time is None:
1285
+ start_time = time.time()
1286
+ if inline_threshold is None:
1287
+ inline_threshold = CALLEE_INLINE_THRESHOLD
1288
+
1289
+ # Check timeout
1290
+ elapsed_ms = (time.time() - start_time) * 1000
1291
+ if elapsed_ms > self._timeout_ms:
1292
+ return [f"{' ' * indent}*(timeout reached)*"], 0
1293
+
1294
+ # Check depth / budget / visited-node ceiling. `expanded` accumulates
1295
+ # every callee visited across the entire expansion; capping on
1296
+ # `_max_nodes` prevents a wide+deep graph from running the caller
1297
+ # out of budget even though each branch respected its own limits.
1298
+ if current_depth > depth or budget <= 0:
1299
+ return [], budget
1300
+ if self._max_nodes and len(expanded) >= self._max_nodes:
1301
+ return [f"{' ' * indent}*(node budget reached)*"], budget
1302
+
1303
+ # Check memoization cache. Key includes indent because rendered
1304
+ # output lines contain indentation prefixes; caching without the
1305
+ # indent would produce wrong-depth whitespace on re-use.
1306
+ cache_key = (node_id, depth - current_depth + 1, indent)
1307
+ if cache_key in self._expansion_cache:
1308
+ cached_results, cached_cost = self._expansion_cache[cache_key]
1309
+ if cached_cost <= budget:
1310
+ return list(cached_results), budget - cached_cost
1311
+
1312
+ results = []
1313
+ tokens_used = 0
1314
+ callees = self.get_callees(node_id)[: self._max_children_per_level]
1315
+
1316
+ for callee_id in callees:
1317
+ if callee_id in expanded:
1318
+ continue
1319
+ expanded.add(callee_id)
1320
+
1321
+ # Check timeout periodically
1322
+ if (time.time() - start_time) * 1000 > self._timeout_ms:
1323
+ results.append(f"{' ' * indent}*(timeout)*")
1324
+ break
1325
+
1326
+ callee_node = self.nodes.get(callee_id)
1327
+ indent_str = " " * indent
1328
+
1329
+ if callee_node:
1330
+ # Resolved node
1331
+ c_file = callee_node["id"].split("::")[0].replace("FILE:", "")
1332
+ c_meta = callee_node.get("metadata", {})
1333
+ c_tokens = c_meta.get("token_count", 999)
1334
+ c_name = c_meta.get("name", "?")
1335
+ c_sig = c_meta.get("signature", f"{c_name}(...)")
1336
+ c_doc = (c_meta.get("docstring", "") or "").split("\n")[0][:80]
1337
+ c_lang = _get_syntax_lang(c_file, self._syntax_lang_map)
1338
+
1339
+ # Inline small functions if budget allows
1340
+ if (
1341
+ c_tokens <= inline_threshold
1342
+ and c_tokens <= budget
1343
+ and os.path.exists(c_file)
1344
+ ):
1345
+ c_start = c_meta.get("lineno", 1)
1346
+ c_end = c_meta.get("end_lineno", c_start + 10)
1347
+ code = self.get_source_segment(c_file, c_start, c_end)
1348
+ # Indent code content for proper markdown list nesting
1349
+ code_indent = indent_str + " "
1350
+ code_indented = "\n".join(
1351
+ code_indent + line for line in code.rstrip().split("\n")
1352
+ )
1353
+ results.append(f"{indent_str}- **{c_name}** ({c_tokens} toks):")
1354
+ results.append(f"{indent_str} ```{c_lang}")
1355
+ results.append(code_indented)
1356
+ results.append(f"{indent_str} ```")
1357
+ tokens_used += c_tokens
1358
+ budget -= c_tokens
1359
+
1360
+ # Recurse if depth allows
1361
+ if current_depth < depth and budget > 0:
1362
+ nested, budget = self._expand_callees_recursive(
1363
+ callee_id,
1364
+ depth,
1365
+ budget,
1366
+ expanded,
1367
+ current_depth + 1,
1368
+ indent + 1,
1369
+ start_time,
1370
+ inline_threshold,
1371
+ )
1372
+ results.extend(nested)
1373
+ else:
1374
+ # Summary mode for larger functions
1375
+ results.append(f"{indent_str}- `{c_sig}` ({c_tokens} toks)")
1376
+ if c_doc:
1377
+ results.append(f"{indent_str} > {c_doc}")
1378
+ else:
1379
+ # Unresolved REF
1380
+ raw_name = callee_id.replace("REF:", "")
1381
+ clean_name = _clean_ref_name(raw_name)
1382
+ results.append(f"{indent_str}- `{clean_name}` (External)")
1383
+
1384
+ # Cache results
1385
+ self._expansion_cache[cache_key] = (results.copy(), tokens_used)
1386
+
1387
+ return results, budget
1388
+
1389
+ def trace_flow(
1390
+ self,
1391
+ start_name: str,
1392
+ direction: str = "forward",
1393
+ depth: int = 3,
1394
+ target: str = None,
1395
+ inline_threshold: int = 100,
1396
+ timeout_ms: int = None,
1397
+ ) -> str:
1398
+ """Trace call flow showing path with inline code.
1399
+
1400
+ Args:
1401
+ start_name: Starting symbol name or node ID
1402
+ direction: 'forward' traces callees, 'backward' traces callers
1403
+ depth: Maximum depth (1-5, default: 3)
1404
+ target: Optional target to stop when reached
1405
+ inline_threshold: Inline code below this token count (default: 100)
1406
+ timeout_ms: Timeout in milliseconds (default: TIMEOUT_MS)
1407
+
1408
+ Returns:
1409
+ Formatted flow trace as markdown string
1410
+ """
1411
+ if timeout_ms is None:
1412
+ timeout_ms = self._timeout_ms
1413
+ # Respect the project's configured `[query] max_depth` ceiling, with
1414
+ # a fallback of 5 for the legacy hard limit. Previously this method
1415
+ # hardcoded `min(depth, 5)` and silently ignored `.descry.toml`.
1416
+ effective_max = self._max_depth if self._max_depth else 5
1417
+ depth = min(depth, effective_max)
1418
+
1419
+ # Resolve start node
1420
+ start_nodes = self.find_nodes_by_name(start_name)
1421
+ if not start_nodes:
1422
+ # Try fuzzy match
1423
+ start_nodes = self.find_nodes_by_name(start_name, fuzzy=True)
1424
+
1425
+ func_nodes = [n for n in start_nodes if n["type"] in ("Function", "Method")]
1426
+ if not func_nodes:
1427
+ if start_nodes:
1428
+ func_nodes = start_nodes[:1] # Use first match even if not function
1429
+ else:
1430
+ return f"No function '{start_name}' found."
1431
+
1432
+ start_node = func_nodes[0]
1433
+ start_id = start_node["id"]
1434
+ start_meta = start_node.get("metadata", {})
1435
+
1436
+ output = [
1437
+ f"### Call Flow: `{start_meta.get('name', start_name)}` ({direction})"
1438
+ ]
1439
+ output.append("")
1440
+
1441
+ # Show disambiguation info if multiple matches
1442
+ if len(func_nodes) > 1:
1443
+ start_file = start_id.split("::")[0].replace("FILE:", "")
1444
+ output.append(
1445
+ f"**Note:** Found {len(func_nodes)} matches for `{start_name}`. Using:"
1446
+ )
1447
+ output.append(f" `{start_id}`")
1448
+ output.append(f" ({start_file})")
1449
+ output.append("")
1450
+ output.append("**Alternatives** (use full node ID to select):")
1451
+ for alt in func_nodes[1:5]: # Show up to 4 alternatives
1452
+ output.append(f" - `{alt['id']}`")
1453
+ if len(func_nodes) > 5:
1454
+ output.append(f" - ... and {len(func_nodes) - 5} more")
1455
+ output.append("")
1456
+
1457
+ visited = set()
1458
+ path_to_target = []
1459
+ start_time = time.time()
1460
+ nodes_visited = 0
1461
+
1462
+ def trace(node_id: str, current_depth: int, path: list) -> bool:
1463
+ nonlocal nodes_visited
1464
+
1465
+ # Check limits
1466
+ elapsed_ms = (time.time() - start_time) * 1000
1467
+ if elapsed_ms > timeout_ms:
1468
+ output.append(" " * (current_depth - 1) + "*(timeout)*")
1469
+ return False
1470
+ if current_depth > depth or node_id in visited:
1471
+ return False
1472
+ if nodes_visited >= self._max_nodes:
1473
+ output.append(" " * (current_depth - 1) + "*(node limit)*")
1474
+ return False
1475
+
1476
+ visited.add(node_id)
1477
+ nodes_visited += 1
1478
+
1479
+ node = self.nodes.get(node_id)
1480
+ if not node:
1481
+ return False
1482
+
1483
+ meta = node.get("metadata", {})
1484
+ name = meta.get("name", "?")
1485
+ tokens = meta.get("token_count", 0)
1486
+ node_type = node.get("type", "?")[:3]
1487
+
1488
+ # Check target reached
1489
+ if target and name == target:
1490
+ path_to_target.extend(path + [node_id])
1491
+ return True
1492
+
1493
+ # Get next level
1494
+ if direction == "forward":
1495
+ next_ids = self.get_callees(node_id)[: self._max_children_per_level]
1496
+ else:
1497
+ next_ids = self.get_callers(name)[: self._max_children_per_level]
1498
+
1499
+ # Format node
1500
+ indent = " " * (current_depth - 1)
1501
+ arrow = "├─" if current_depth > 1 else ""
1502
+ output.append(f"{indent}{arrow}[{node_type}] **{name}** ({tokens} toks)")
1503
+
1504
+ # Inline small functions
1505
+ if tokens <= inline_threshold and tokens > 0:
1506
+ file_path = node_id.split("::")[0].replace("FILE:", "")
1507
+ if os.path.exists(file_path):
1508
+ start_line = meta.get("lineno", 1)
1509
+ end_line = meta.get("end_lineno", start_line + 10)
1510
+ code = self.get_source_segment(file_path, start_line, end_line)
1511
+ lang = _get_syntax_lang(file_path, self._syntax_lang_map)
1512
+ output.append(f"{indent} ```{lang}")
1513
+ output.append(code.rstrip())
1514
+ output.append(f"{indent} ```")
1515
+
1516
+ # Recurse (limit branching)
1517
+ for next_id in next_ids:
1518
+ if trace(next_id, current_depth + 1, path + [node_id]):
1519
+ return True
1520
+ return False
1521
+
1522
+ trace(start_id, 1, [])
1523
+
1524
+ if target and path_to_target:
1525
+ output.append("")
1526
+ output.append(f"#### Path to `{target}`:")
1527
+ path_names = [p.split("::")[-1] for p in path_to_target]
1528
+ output.append(" -> ".join(path_names))
1529
+
1530
+ return "\n".join(output)
1531
+
1532
+ def trace_flow_structured(
1533
+ self,
1534
+ start_name: str,
1535
+ direction: str = "forward",
1536
+ depth: int = 3,
1537
+ target: str = None,
1538
+ inline_threshold: int = 100,
1539
+ timeout_ms: int = None,
1540
+ ) -> dict:
1541
+ """Trace call flow, returning a structured tree for UI rendering.
1542
+
1543
+ Returns dict with keys: root, alternatives (optional), note (optional).
1544
+ Each tree node has: id, name, type, tokens, file, line, code, lang, children.
1545
+ """
1546
+ if timeout_ms is None:
1547
+ timeout_ms = self._timeout_ms
1548
+ effective_max = self._max_depth if self._max_depth else 5
1549
+ depth = min(depth, effective_max)
1550
+
1551
+ # Resolve start node
1552
+ start_nodes = self.find_nodes_by_name(start_name)
1553
+ if not start_nodes:
1554
+ start_nodes = self.find_nodes_by_name(start_name, fuzzy=True)
1555
+
1556
+ func_nodes = [n for n in start_nodes if n["type"] in ("Function", "Method")]
1557
+ if not func_nodes:
1558
+ if start_nodes:
1559
+ func_nodes = start_nodes[:1]
1560
+ else:
1561
+ return {"error": f"No function '{start_name}' found."}
1562
+
1563
+ start_node = func_nodes[0]
1564
+ start_id = start_node["id"]
1565
+
1566
+ # Build alternatives list for disambiguation
1567
+ alternatives = []
1568
+ note = None
1569
+ if len(func_nodes) > 1:
1570
+ start_file = start_id.split("::")[0].replace("FILE:", "")
1571
+ note = (
1572
+ f'Found {len(func_nodes)} matches for "{start_name}". '
1573
+ f"Using: {start_id} ({start_file})"
1574
+ )
1575
+ for alt in func_nodes[1:6]:
1576
+ alt_meta = alt.get("metadata", {})
1577
+ alt_file = alt["id"].split("::")[0].replace("FILE:", "")
1578
+ alternatives.append(
1579
+ {
1580
+ "id": alt["id"],
1581
+ "name": alt_meta.get("name", "?"),
1582
+ "file": alt_file,
1583
+ }
1584
+ )
1585
+
1586
+ visited = set()
1587
+ start_time = time.time()
1588
+ nodes_visited = 0
1589
+
1590
+ def build_tree(node_id: str, current_depth: int) -> dict | None:
1591
+ nonlocal nodes_visited
1592
+
1593
+ elapsed_ms = (time.time() - start_time) * 1000
1594
+ if elapsed_ms > timeout_ms:
1595
+ return None
1596
+ if current_depth > depth or node_id in visited:
1597
+ return None
1598
+ if nodes_visited >= self._max_nodes:
1599
+ return None
1600
+
1601
+ visited.add(node_id)
1602
+ nodes_visited += 1
1603
+
1604
+ node = self.nodes.get(node_id)
1605
+ if not node:
1606
+ return None
1607
+
1608
+ meta = node.get("metadata", {})
1609
+ name = meta.get("name", "?")
1610
+ tokens = meta.get("token_count", 0)
1611
+ node_type = node.get("type", "?")[:3]
1612
+ file_path = node_id.split("::")[0].replace("FILE:", "")
1613
+ line = meta.get("lineno")
1614
+ lang = _get_syntax_lang(file_path, self._syntax_lang_map)
1615
+
1616
+ # Inline small functions
1617
+ code = None
1618
+ if tokens <= inline_threshold and tokens > 0:
1619
+ if os.path.exists(file_path):
1620
+ start_line = meta.get("lineno", 1)
1621
+ end_line = meta.get("end_lineno", start_line + 10)
1622
+ code = self.get_source_segment(file_path, start_line, end_line)
1623
+
1624
+ # Recurse into children
1625
+ if direction == "forward":
1626
+ next_ids = self.get_callees(node_id)[: self._max_children_per_level]
1627
+ else:
1628
+ next_ids = self.get_callers(name)[: self._max_children_per_level]
1629
+
1630
+ children = []
1631
+ for next_id in next_ids:
1632
+ child = build_tree(next_id, current_depth + 1)
1633
+ if child:
1634
+ children.append(child)
1635
+
1636
+ return {
1637
+ "id": node_id,
1638
+ "name": name,
1639
+ "type": node_type,
1640
+ "tokens": tokens,
1641
+ "file": file_path,
1642
+ "line": line,
1643
+ "code": code,
1644
+ "lang": lang,
1645
+ "children": children,
1646
+ }
1647
+
1648
+ root = build_tree(start_id, 1)
1649
+
1650
+ result = {"root": root}
1651
+ if note:
1652
+ result["note"] = note
1653
+ if alternatives:
1654
+ result["alternatives"] = alternatives
1655
+
1656
+ return result
1657
+
1658
+ def flatten_class(self, class_node_id):
1659
+ """
1660
+ Traverses INHERITS edges to build a full list of methods for a class.
1661
+ """
1662
+ class_node = self.nodes.get(class_node_id)
1663
+ if not class_node:
1664
+ return f"ERROR: Node '{class_node_id}' not found in graph."
1665
+ if class_node["type"] != "Class":
1666
+ return f"ERROR: '{class_node_id}' is a {class_node['type']}, not a Class."
1667
+
1668
+ # 1. Collect Inheritance Chain
1669
+ chain = [] # List of class nodes, starting from root to leaf
1670
+ visited = set()
1671
+
1672
+ def traverse_up(current_id):
1673
+ if current_id in visited:
1674
+ return
1675
+ visited.add(current_id)
1676
+
1677
+ # Find definition node if current_id is a REF
1678
+ if current_id.startswith("REF:"):
1679
+ # Try to find the Class node definition
1680
+ name = current_id.replace("REF:", "")
1681
+ matches = self.find_nodes_by_name(name)
1682
+ class_matches = [m for m in matches if m["type"] == "Class"]
1683
+ if class_matches:
1684
+ current_node = class_matches[0]
1685
+ else:
1686
+ # External class or not found
1687
+ chain.insert(
1688
+ 0, {"id": current_id, "name": name, "type": "External"}
1689
+ )
1690
+ return
1691
+ else:
1692
+ current_node = self.nodes.get(current_id)
1693
+
1694
+ if current_node:
1695
+ chain.insert(0, current_node)
1696
+ # Find parents
1697
+ for edge in self.outgoing[current_node["id"]]:
1698
+ if edge["relation"] == "INHERITS":
1699
+ traverse_up(edge["target"])
1700
+
1701
+ traverse_up(class_node_id)
1702
+
1703
+ # 2. Merge Methods
1704
+ methods = {} # Name -> Node/Signature
1705
+
1706
+ output = []
1707
+ output.append(f"### Flattened View: `{class_node['metadata']['name']}`")
1708
+ output.append("#### Inheritance Chain:")
1709
+ output.append(
1710
+ " -> ".join(
1711
+ [c.get("metadata", {}).get("name", c.get("name")) for c in chain]
1712
+ )
1713
+ )
1714
+ output.append("\n#### Effective API:")
1715
+
1716
+ for cls in chain:
1717
+ if cls.get("type") == "External":
1718
+ continue
1719
+
1720
+ # Find methods defined in this class
1721
+ # (Look at edges outgoing from class node with 'DEFINES' relation)
1722
+ for edge in self.outgoing[cls["id"]]:
1723
+ if edge["relation"] == "DEFINES":
1724
+ child = self.nodes.get(edge["target"])
1725
+ if child and child["type"] == "Method":
1726
+ name = child["metadata"]["name"]
1727
+ methods[name] = child
1728
+
1729
+ # Output sorted methods
1730
+ for name in sorted(methods.keys()):
1731
+ m = methods[name]
1732
+ meta = m["metadata"]
1733
+ sig = meta.get("signature", name)
1734
+ defined_in = m["id"].split("::")[0].replace("FILE:", "")
1735
+ output.append(f"- `{sig}`")
1736
+ output.append(f" *Defined in: {defined_in}*")
1737
+ if meta.get("docstring"):
1738
+ output.append(f" > {meta['docstring'].splitlines()[0]}")
1739
+
1740
+ return "\n".join(output)
1741
+
1742
+ def _build_idf_cache(self):
1743
+ """Build IDF (Inverse Document Frequency) cache for TF-IDF search.
1744
+
1745
+ IDF = log(N / df) where:
1746
+ - N = total number of documents (nodes)
1747
+ - df = document frequency (number of docs containing the term)
1748
+ """
1749
+ doc_freq = defaultdict(int) # term -> count of docs containing it
1750
+ total_docs = len(self.data["nodes"])
1751
+
1752
+ for node in self.data["nodes"]:
1753
+ meta = node.get("metadata", {})
1754
+ # Tokenize name and docstring
1755
+ text = f"{meta.get('name', '')} {meta.get('docstring', '')}"
1756
+ # Simple tokenization: split on non-alphanumeric, lowercase
1757
+ tokens = set(re.findall(r"[^\W\d]\w*", text.lower(), flags=re.UNICODE))
1758
+ for token in tokens:
1759
+ doc_freq[token] += 1
1760
+
1761
+ # Compute IDF with smoothing to avoid log(0)
1762
+ idf = {}
1763
+ for term, df in doc_freq.items():
1764
+ # Add 1 to df for smoothing (avoids division issues)
1765
+ idf[term] = math.log((total_docs + 1) / (df + 1)) + 1
1766
+ return idf
1767
+
1768
+ def search_docs(
1769
+ self, query_terms, lang=None, crate=None, symbol_type=None, exclude_tests=False
1770
+ ):
1771
+ """
1772
+ Searches docstrings and names for keywords using TF-IDF scoring.
1773
+ Returns matches sorted by relevance.
1774
+
1775
+ Args:
1776
+ query_terms: List of search terms
1777
+ lang: Filter by language ('rust', 'typescript', 'python', 'svelte', 'all')
1778
+ crate: Filter by crate/module (e.g., 'backend', 'webapp')
1779
+ symbol_type: Filter by type ('function', 'class', 'method', 'constant', 'file', 'all')
1780
+ exclude_tests: If True, filter out test files and test functions (default: False)
1781
+
1782
+ Scoring:
1783
+ - Name matches: TF-IDF * 10 (high weight for name match)
1784
+ - Docstring matches: TF-IDF * 1 (standard weight)
1785
+ - Exact name match: +50 bonus
1786
+ - All terms present (phrase): +30 bonus per additional term
1787
+ - Adjacent terms (phrase proximity): +20 bonus
1788
+ """
1789
+ results = []
1790
+ # D.3: drop empty/whitespace-only terms before scoring — `"" in s` is
1791
+ # always True and would match every node.
1792
+ terms = [t.lower() for t in query_terms if t and t.strip()]
1793
+ num_terms = len(terms)
1794
+ if num_terms == 0:
1795
+ return []
1796
+
1797
+ # Apply filters if any specified
1798
+ if lang or crate or symbol_type or exclude_tests:
1799
+ self._ensure_filter_indices()
1800
+ candidate_ids = set(self.nodes.keys())
1801
+
1802
+ # Filter out test files and functions if requested
1803
+ if exclude_tests and self._index_is_test:
1804
+ candidate_ids -= self._index_is_test
1805
+
1806
+ if crate:
1807
+ # If crate filter specified but doesn't exist, return empty results
1808
+ if crate not in self._index_by_crate:
1809
+ return []
1810
+ candidate_ids &= self._index_by_crate[crate]
1811
+ if lang and lang != "all" and lang in self._index_by_lang:
1812
+ candidate_ids &= self._index_by_lang[lang]
1813
+ if symbol_type and symbol_type != "all":
1814
+ type_map = {
1815
+ "function": "Function",
1816
+ "class": "Class",
1817
+ "method": "Method",
1818
+ "constant": "Constant",
1819
+ "file": "File",
1820
+ }
1821
+ type_key = type_map.get(symbol_type.lower())
1822
+ if type_key and type_key in self._index_by_type:
1823
+ candidate_ids &= self._index_by_type[type_key]
1824
+
1825
+ candidates = [self.nodes[nid] for nid in candidate_ids]
1826
+ else:
1827
+ candidates = self.data["nodes"]
1828
+
1829
+ for node in candidates:
1830
+ score = 0.0
1831
+ meta = node.get("metadata", {})
1832
+ name = meta.get("name", "").lower()
1833
+ doc = meta.get("docstring", "").lower()
1834
+ sig = meta.get("signature", "").lower()
1835
+
1836
+ # Combine searchable text
1837
+ full_text = f"{name} {doc} {sig}"
1838
+
1839
+ # Tokenize for TF calculation
1840
+ name_tokens = re.findall(r"[^\W\d]\w*", name, flags=re.UNICODE)
1841
+ doc_tokens = re.findall(r"[^\W\d]\w*", doc, flags=re.UNICODE)
1842
+ full_tokens = re.findall(r"[^\W\d]\w*", full_text, flags=re.UNICODE)
1843
+
1844
+ terms_found = 0
1845
+ for term in terms:
1846
+ idf = self._idf_cache.get(term, 1.0) # Default IDF if term not in cache
1847
+
1848
+ # Term frequency in name (normalized by name length)
1849
+ name_tf = name_tokens.count(term) / max(len(name_tokens), 1)
1850
+ if name_tf > 0:
1851
+ score += name_tf * idf * 10 # Higher weight for name match
1852
+ terms_found += 1
1853
+
1854
+ # Term frequency in docstring (normalized)
1855
+ doc_tf = doc_tokens.count(term) / max(len(doc_tokens), 1)
1856
+ if doc_tf > 0:
1857
+ score += doc_tf * idf * 1
1858
+ if name_tf == 0: # Only count once
1859
+ terms_found += 1
1860
+
1861
+ # Check in signature too
1862
+ if term in sig:
1863
+ score += idf * 2
1864
+ if name_tf == 0 and doc_tf == 0:
1865
+ terms_found += 1
1866
+
1867
+ # Bonus for exact name match
1868
+ if name == term:
1869
+ score += 50
1870
+
1871
+ # Bonus for substring match in name (partial matching)
1872
+ elif term in name:
1873
+ score += idf * 5
1874
+
1875
+ # Multi-term query handling - use tier-based scoring
1876
+ # Full matches ALWAYS rank above partial matches
1877
+ if num_terms > 1:
1878
+ if terms_found == num_terms:
1879
+ # ALL TERMS PRESENT - boost to tier 2 (1000+ base)
1880
+ # This ensures full matches always outrank partial matches
1881
+ score = 1000 + score * 2 # Base tier + amplified TF-IDF
1882
+
1883
+ # Check for adjacent terms (phrase proximity)
1884
+ phrase = "_".join(terms) # e.g., "deployment_dispatch"
1885
+ phrase_space = " ".join(terms) # e.g., "deployment dispatch"
1886
+
1887
+ if phrase in name or phrase in full_text:
1888
+ score += 500 # Strong bonus for exact phrase as compound word
1889
+ elif phrase_space in full_text:
1890
+ score += 200 # Bonus for adjacent terms
1891
+
1892
+ # Check for terms appearing within 3 tokens of each other
1893
+ for i, token in enumerate(full_tokens):
1894
+ if token == terms[0]:
1895
+ window = full_tokens[i : i + num_terms + 2]
1896
+ if all(t in window for t in terms):
1897
+ score += 100 # Proximity bonus
1898
+ break
1899
+ elif terms_found > 0:
1900
+ # PARTIAL MATCH - stays in tier 1 (0-999)
1901
+ # Apply harsh penalty: partial matches are deprioritized
1902
+ match_ratio = terms_found / num_terms
1903
+ score *= match_ratio * match_ratio # Quadratic penalty
1904
+ # Cap partial matches below the full-match tier
1905
+ score = min(score, 999)
1906
+
1907
+ if score > 0:
1908
+ # Apply in-degree boost (symbols with more callers are more important)
1909
+ # Use logarithmic scaling to prevent extremely popular symbols from dominating
1910
+ in_degree = node.get("metadata", {}).get("in_degree", 0)
1911
+ if in_degree > 0:
1912
+ # Log scaling: in_degree of 1 -> 0, 10 -> 2.3, 100 -> 4.6
1913
+ in_degree_boost = math.log(1 + in_degree) * 5
1914
+ score += in_degree_boost
1915
+
1916
+ # Type preference: Functions/Classes ranked above Constants for same score
1917
+ # Adds small tie-breaker that doesn't overwhelm TF-IDF relevance
1918
+ node_type = node.get("type", "")
1919
+ type_boost = {
1920
+ "Function": 0.5,
1921
+ "Method": 0.5,
1922
+ "Class": 0.4,
1923
+ "Constant": 0.1,
1924
+ "File": 0.0,
1925
+ }.get(node_type, 0.2)
1926
+ score += type_boost
1927
+
1928
+ results.append((score, node))
1929
+
1930
+ # Sort by score desc
1931
+ results.sort(key=lambda x: x[0], reverse=True)
1932
+ return [r[1] for r in results]
1933
+
1934
+ def get_node_info(self, node_id):
1935
+ return self.nodes.get(node_id)
1936
+
1937
+ def find_nodes_by_name(self, name, fuzzy=False):
1938
+ """Find nodes by name, supporting multiple matching strategies.
1939
+
1940
+ Supports:
1941
+ - Exact name match: "start" matches any node with name="start"
1942
+ - Naming convention variants: "getClient" matches "get_client" (camelCase/snake_case)
1943
+ - Qualified name: "ThoraxServer::start" matches node IDs ending with that pattern
1944
+ - File path suffix: "server.rs" matches files ending with that path
1945
+ - Partial qualified: "auth::check_permission" matches IDs containing that pattern
1946
+ - Fuzzy/prefix matching (when fuzzy=True): "validate_token" matches "validate_token_format"
1947
+
1948
+ Args:
1949
+ name: Name or pattern to search for
1950
+ fuzzy: If True, also matches names that start with or contain the search term
1951
+ """
1952
+ # D.3: empty/whitespace name would match every node (`"" in s` is True).
1953
+ if not name or not name.strip():
1954
+ return []
1955
+ exact_matches = []
1956
+ variant_matches = []
1957
+ fuzzy_matches = []
1958
+
1959
+ # Get naming convention variants (e.g., getClient -> [getClient, get_client])
1960
+ name_variants = _get_name_variants(name)
1961
+
1962
+ for node in self.data["nodes"]:
1963
+ meta = node.get("metadata", {})
1964
+ node_id = node["id"]
1965
+ node_name = meta.get("name", "")
1966
+ node_name_normalized = _normalize_name(node_name) if node_name else ""
1967
+
1968
+ # Exact name match
1969
+ if node_name == name:
1970
+ exact_matches.append(node)
1971
+ continue
1972
+
1973
+ # Naming convention variant match (e.g., getClient matches get_client)
1974
+ if node_name and (
1975
+ node_name_normalized in name_variants
1976
+ or node_name.lower() in [v.lower() for v in name_variants]
1977
+ ):
1978
+ variant_matches.append(node)
1979
+ continue
1980
+
1981
+ # File path suffix match
1982
+ if str(meta.get("path", "")).endswith(name):
1983
+ exact_matches.append(node)
1984
+ continue
1985
+
1986
+ # Qualified name match (e.g., "ThoraxServer::start")
1987
+ if "::" in name:
1988
+ # Check if node ID ends with the qualified name
1989
+ if node_id.endswith(f"::{name}") or node_id.endswith(name):
1990
+ exact_matches.append(node)
1991
+ continue
1992
+ # Check if the qualified pattern appears in the node ID
1993
+ if name in node_id:
1994
+ exact_matches.append(node)
1995
+ continue
1996
+ # Also try with normalized variants
1997
+ for variant in name_variants:
1998
+ if variant in node_id.lower():
1999
+ variant_matches.append(node)
2000
+ break
2001
+
2002
+ # Fuzzy matching: prefix or contains (for simple names)
2003
+ if fuzzy and "::" not in name and len(name) >= 3:
2004
+ # Prefix match on node name (e.g., "validate_token" matches "validate_token_format")
2005
+ if node_name.startswith(name):
2006
+ fuzzy_matches.append(node)
2007
+ continue
2008
+ # Contains match in node ID (for cases like searching "dispatch" finding "dispatch_to_handler")
2009
+ if f"::{name}" in node_id.lower() or node_id.lower().endswith(
2010
+ name.lower()
2011
+ ):
2012
+ fuzzy_matches.append(node)
2013
+ continue
2014
+
2015
+ # Return exact matches first, then variant matches, then fuzzy if no others
2016
+ if exact_matches:
2017
+ return exact_matches
2018
+ if variant_matches:
2019
+ return variant_matches
2020
+ return fuzzy_matches
2021
+
2022
+ def find_trait_impls(self, method_name: str, trait_name: str = None) -> list:
2023
+ """Find all implementations of a trait method across the codebase.
2024
+
2025
+ When you know a trait method name (e.g., 'from_request_parts') but need
2026
+ to find which structs implement it, this discovers all implementations.
2027
+
2028
+ Args:
2029
+ method_name: The method name to search for (e.g., 'from_request_parts')
2030
+ trait_name: Optional filter by specific trait (e.g., 'FromRequestParts')
2031
+
2032
+ Returns:
2033
+ List of nodes that are trait implementations matching the criteria.
2034
+ Each node includes metadata with 'trait_impl' field showing which trait
2035
+ it implements.
2036
+
2037
+ Example:
2038
+ find_trait_impls("from_request_parts")
2039
+ # Returns JwtAuth, OptionalJwtAuth, SseAuth, Pagination, etc.
2040
+
2041
+ find_trait_impls("from_request_parts", "FromRequestParts")
2042
+ # Returns only FromRequestParts implementations
2043
+ """
2044
+ results = []
2045
+
2046
+ for node in self.data["nodes"]:
2047
+ # Only consider Method and Function types
2048
+ if node["type"] not in ("Method", "Function"):
2049
+ continue
2050
+
2051
+ meta = node.get("metadata", {})
2052
+ node_name = meta.get("name", "")
2053
+
2054
+ # Check if method name matches
2055
+ if node_name != method_name:
2056
+ continue
2057
+
2058
+ # Check if this is a trait implementation
2059
+ impl_trait = meta.get("trait_impl")
2060
+ if impl_trait:
2061
+ # If trait_name filter is specified, check it matches
2062
+ if trait_name is None or impl_trait == trait_name:
2063
+ results.append(node)
2064
+
2065
+ return results
2066
+
2067
+ def get_callers(self, func_name, fuzzy=False):
2068
+ """Find all callers of a function or users of a struct/class.
2069
+
2070
+ OPTIMIZED: Uses incoming index for O(in-degree) instead of O(E) scan.
2071
+
2072
+ Supports:
2073
+ - Simple name: "start" matches calls to any function named start
2074
+ - Qualified name: "ThoraxServer::start" matches that specific method
2075
+ - Full node ID: "FILE:path::Class::method" for exact matching
2076
+ - Struct names: includes INSTANTIATES edges for struct usage tracking
2077
+ - Fuzzy matching (when fuzzy=True): "validate_token" matches "validate_token_format"
2078
+
2079
+ Args:
2080
+ func_name: Function/method name to find callers for
2081
+ fuzzy: If True, also matches names that start with the search term
2082
+ """
2083
+ # Include INSTANTIATES for struct/class usage tracking
2084
+ relevant_relations = {
2085
+ "CALLS",
2086
+ "CALLS_RESOLVED",
2087
+ "INSTANTIATES",
2088
+ "INSTANTIATES_RESOLVED",
2089
+ }
2090
+
2091
+ # Step 1: Find all target node IDs that match func_name
2092
+ target_ids = self._resolve_target_ids(func_name, fuzzy)
2093
+
2094
+ # Step 2: Use incoming index to find callers - O(in-degree) per target
2095
+ callers = set()
2096
+ for target_id in target_ids:
2097
+ for edge in self.incoming.get(target_id, []):
2098
+ if edge["relation"] in relevant_relations:
2099
+ callers.add(edge["source"])
2100
+
2101
+ return list(callers)
2102
+
2103
+ def _resolve_target_ids(self, func_name: str, fuzzy: bool = False) -> set:
2104
+ """Resolve a function name to all possible target IDs in the graph.
2105
+
2106
+ Returns node IDs and REF: IDs that match the given name.
2107
+ """
2108
+ target_ids = set()
2109
+ base_name = func_name.split("::")[-1].split(".")[-1]
2110
+
2111
+ # Direct matches - these are the most common
2112
+ target_ids.add(func_name)
2113
+ target_ids.add(f"REF:{func_name}")
2114
+ target_ids.add(f"REF:{base_name}")
2115
+
2116
+ # Find resolved nodes that match the name
2117
+ matches = self.find_nodes_by_name(func_name, fuzzy=fuzzy)
2118
+ for match in matches:
2119
+ target_ids.add(match["id"])
2120
+
2121
+ # For qualified names, also add the qualified REF
2122
+ if "::" in func_name:
2123
+ target_ids.add(f"REF:{func_name}")
2124
+
2125
+ # Build additional targets from existing nodes/edges that end with our name
2126
+ # This catches cases like REF:Foo::Bar when searching for "Bar"
2127
+ for node_id in self.nodes.keys():
2128
+ if node_id.endswith(f"::{func_name}") or node_id.endswith(f"::{base_name}"):
2129
+ target_ids.add(node_id)
2130
+
2131
+ # Also check for REF targets in incoming edges (for unresolved calls)
2132
+ # This is still needed because REF: targets may not be nodes themselves
2133
+ for target_id in list(self.incoming.keys()):
2134
+ if target_id.startswith("REF:"):
2135
+ ref_name = target_id.replace("REF:", "")
2136
+ # Exact match
2137
+ if ref_name == func_name or ref_name == base_name:
2138
+ target_ids.add(target_id)
2139
+ # Suffix match
2140
+ elif ref_name.endswith(f"::{func_name}") or ref_name.endswith(
2141
+ f".{func_name}"
2142
+ ):
2143
+ target_ids.add(target_id)
2144
+ elif ref_name.endswith(f"::{base_name}") or ref_name.endswith(
2145
+ f".{base_name}"
2146
+ ):
2147
+ target_ids.add(target_id)
2148
+ # Fuzzy prefix match
2149
+ elif fuzzy and len(func_name) >= 3:
2150
+ clean_ref = ref_name.split("::")[-1].split(".")[-1]
2151
+ if clean_ref.startswith(func_name):
2152
+ target_ids.add(target_id)
2153
+
2154
+ return target_ids
2155
+
2156
+ def get_callees(self, func_node_id):
2157
+ """Get unique callees for a function, deduplicated.
2158
+
2159
+ For unresolved references (REF:...), deduplicates by cleaned/normalized name
2160
+ to avoid showing iter_mut and plan.steps.iter_mut as separate entries,
2161
+ and to handle duplicates like Uuid::parse_str appearing multiple times.
2162
+ """
2163
+ seen_ids = set() # Full target IDs
2164
+ seen_clean_names = set() # Cleaned names for unresolved refs
2165
+ callees = []
2166
+
2167
+ for edge in self.outgoing[func_node_id]:
2168
+ # Updated to support both resolved and unresolved edges
2169
+ if edge["relation"] in ("CALLS", "CALLS_RESOLVED"):
2170
+ target = edge["target"]
2171
+
2172
+ # For resolved references, dedupe by exact ID
2173
+ if not target.startswith("REF:"):
2174
+ if target not in seen_ids:
2175
+ seen_ids.add(target)
2176
+ callees.append(target)
2177
+ else:
2178
+ # For unresolved refs, use cleaned name for deduplication
2179
+ # This handles both method chains (a.b.method) and qualified names (Type::method)
2180
+ ref_name = target.replace("REF:", "")
2181
+ clean_name = _clean_ref_name(ref_name)
2182
+
2183
+ if clean_name not in seen_clean_names:
2184
+ seen_clean_names.add(clean_name)
2185
+ callees.append(target)
2186
+
2187
+ return callees
2188
+
2189
+ def find_call_path(
2190
+ self,
2191
+ start_name: str,
2192
+ end_name: str,
2193
+ max_depth: int = 10,
2194
+ direction: str = "forward",
2195
+ ) -> list:
2196
+ """Find shortest call path between two symbols using BFS.
2197
+
2198
+ Much more focused than trace_flow which shows entire call trees.
2199
+ Use for "how does X reach Y" questions.
2200
+
2201
+ Args:
2202
+ start_name: Starting symbol name (e.g., 'handle_request')
2203
+ end_name: Target symbol name (e.g., 'validate_token')
2204
+ max_depth: Maximum path length (default: 10)
2205
+ direction: 'forward' (start calls end) or 'backward' (end calls start)
2206
+
2207
+ Returns:
2208
+ List of hops, each containing:
2209
+ - caller_id: Full node ID of the caller
2210
+ - caller_name: Short name
2211
+ - callee_id: Full node ID of the callee
2212
+ - callee_name: Short name
2213
+ - call_line: Line number where call occurs
2214
+ - call_snippet: Code snippet around the call (3 lines)
2215
+ - file_path: File containing the call
2216
+
2217
+ Example:
2218
+ find_call_path("create_deployment", "dispatch_to_handler")
2219
+ # Returns the direct path with code snippets at each hop
2220
+ """
2221
+ from collections import deque
2222
+
2223
+ # Respect configured depth ceiling, and cap visited-node expansion
2224
+ # and wall time so a pathological graph shape cannot stall the
2225
+ # service. Previously this BFS had only `max_depth` and could chew
2226
+ # through the entire call graph with no budget.
2227
+ effective_max_depth = self._max_depth if self._max_depth else 10
2228
+ max_depth = min(max_depth, effective_max_depth)
2229
+ node_budget = self._max_nodes if self._max_nodes else 2000
2230
+ deadline = time.time() + (self._timeout_ms / 1000.0)
2231
+
2232
+ # Resolve start and end to node IDs
2233
+ start_nodes = self.find_nodes_by_name(start_name)
2234
+ end_nodes = self.find_nodes_by_name(end_name)
2235
+
2236
+ if not start_nodes:
2237
+ return []
2238
+ if not end_nodes:
2239
+ return []
2240
+
2241
+ start_id = start_nodes[0]["id"]
2242
+ end_ids = {n["id"] for n in end_nodes}
2243
+
2244
+ # BFS with path tracking
2245
+ queue = deque([(start_id, [])]) # (node_id, path_so_far)
2246
+ visited = {start_id}
2247
+ # Pre-index REF: name resolution once per call to avoid O(N) scans
2248
+ # inside the inner loop — noticeably faster on large graphs and
2249
+ # cheaper against the node_budget.
2250
+ ref_cache: dict[str, str | None] = {}
2251
+
2252
+ while queue:
2253
+ if time.time() > deadline or len(visited) >= node_budget:
2254
+ return [] # Budget exhausted — caller treats as "no path"
2255
+
2256
+ current_id, path = queue.popleft()
2257
+
2258
+ # Get edges based on direction
2259
+ if direction == "forward":
2260
+ edges = [
2261
+ e
2262
+ for e in self.outgoing.get(current_id, [])
2263
+ if e["relation"] in ("CALLS", "CALLS_RESOLVED")
2264
+ ]
2265
+ else:
2266
+ edges = [
2267
+ e
2268
+ for e in self.incoming.get(current_id, [])
2269
+ if e["relation"] in ("CALLS", "CALLS_RESOLVED")
2270
+ ]
2271
+
2272
+ for edge in edges:
2273
+ next_id = edge["target"] if direction == "forward" else edge["source"]
2274
+
2275
+ # Try to resolve REF: nodes to actual nodes (memoized per call).
2276
+ if next_id.startswith("REF:"):
2277
+ ref_name = next_id.replace("REF:", "")
2278
+ if ref_name in ref_cache:
2279
+ resolved_id = ref_cache[ref_name]
2280
+ else:
2281
+ resolved = self.find_nodes_by_name(ref_name)
2282
+ resolved_id = resolved[0]["id"] if resolved else None
2283
+ ref_cache[ref_name] = resolved_id
2284
+ if resolved_id is None:
2285
+ continue
2286
+ next_id = resolved_id
2287
+
2288
+ if next_id in visited:
2289
+ continue
2290
+ visited.add(next_id)
2291
+
2292
+ # Build hop info
2293
+ hop = self._build_hop_info(edge, current_id, next_id, direction)
2294
+ new_path = path + [hop]
2295
+
2296
+ # Check if we reached the target
2297
+ if next_id in end_ids:
2298
+ return new_path
2299
+
2300
+ if len(new_path) < max_depth:
2301
+ queue.append((next_id, new_path))
2302
+
2303
+ return [] # No path found
2304
+
2305
+ def _build_hop_info(
2306
+ self, edge: dict, caller_id: str, callee_id: str, direction: str
2307
+ ) -> dict:
2308
+ """Build hop info dictionary with code snippet.
2309
+
2310
+ Args:
2311
+ edge: The edge connecting caller to callee
2312
+ caller_id: Full node ID of the caller
2313
+ callee_id: Full node ID of the callee
2314
+ direction: 'forward' or 'backward' (affects which node is source)
2315
+
2316
+ Returns:
2317
+ Dict with hop details including call site and snippet
2318
+ """
2319
+ # When direction is backward, the roles are reversed
2320
+ if direction == "backward":
2321
+ caller_id, callee_id = callee_id, caller_id
2322
+
2323
+ caller_node = self.nodes.get(caller_id, {})
2324
+ callee_node = self.nodes.get(callee_id, {})
2325
+
2326
+ caller_meta = caller_node.get("metadata", {})
2327
+ callee_meta = callee_node.get("metadata", {})
2328
+
2329
+ call_line = edge.get("metadata", {}).get("lineno")
2330
+ file_path = caller_id.split("::")[0].replace("FILE:", "")
2331
+
2332
+ # Get code snippet around call site (3 lines: before, call, after)
2333
+ snippet = ""
2334
+ if call_line and os.path.exists(file_path):
2335
+ snippet = self.get_source_segment(file_path, call_line - 1, call_line + 2)
2336
+
2337
+ return {
2338
+ "caller_id": caller_id,
2339
+ "caller_name": caller_meta.get("name", caller_id.split("::")[-1]),
2340
+ "callee_id": callee_id,
2341
+ "callee_name": callee_meta.get("name", callee_id.split("::")[-1]),
2342
+ "call_line": call_line,
2343
+ "call_snippet": snippet.strip() if snippet else "",
2344
+ "file_path": file_path,
2345
+ }