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/__init__.py +4 -0
- descry/_env.py +30 -0
- descry/_graph.py +36 -0
- descry/ast_grep.py +382 -0
- descry/cli.py +457 -0
- descry/cross_lang.py +404 -0
- descry/embeddings.py +593 -0
- descry/generate.py +3579 -0
- descry/git_history.py +1631 -0
- descry/handlers.py +1907 -0
- descry/mcp_server.py +355 -0
- descry/query.py +2345 -0
- descry/scip/__init__.py +0 -0
- descry/scip/cache.py +1047 -0
- descry/scip/parser.py +531 -0
- descry/scip/pb2.py +92 -0
- descry/scip/support.py +174 -0
- descry/tree_sitter_parser.py +356 -0
- descry/web/__init__.py +0 -0
- descry/web/server.py +1457 -0
- descry/web/web/index.html +2444 -0
- descry/web/web/style.css +1791 -0
- descry_codegraph-0.1.0.dist-info/METADATA +345 -0
- descry_codegraph-0.1.0.dist-info/RECORD +27 -0
- descry_codegraph-0.1.0.dist-info/WHEEL +4 -0
- descry_codegraph-0.1.0.dist-info/entry_points.txt +5 -0
- descry_codegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
}
|