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/generate.py
ADDED
|
@@ -0,0 +1,3579 @@
|
|
|
1
|
+
"""Generate codebase knowledge graph with optional SCIP-based type-aware resolution."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
# Configure logging for consistent error reporting
|
|
11
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Refuse to index individual source files above this size. Matches the
|
|
15
|
+
# /api/source cap in web/server.py so the indexer and viewer share a budget.
|
|
16
|
+
_MAX_SOURCE_FILE_BYTES = 10 * 1024 * 1024 # 10 MiB
|
|
17
|
+
|
|
18
|
+
# Try to import ast-grep integration for improved CALLS detection
|
|
19
|
+
try:
|
|
20
|
+
from descry.ast_grep import (
|
|
21
|
+
ast_grep_available,
|
|
22
|
+
extract_calls_rust,
|
|
23
|
+
extract_calls_typescript,
|
|
24
|
+
extract_imports_typescript,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
USE_AST_GREP = ast_grep_available()
|
|
28
|
+
except ImportError:
|
|
29
|
+
USE_AST_GREP = False
|
|
30
|
+
extract_imports_typescript = None
|
|
31
|
+
logger.debug("ast-grep not available, using regex fallback for CALLS detection")
|
|
32
|
+
|
|
33
|
+
# Try to import SCIP support for type-aware call resolution
|
|
34
|
+
try:
|
|
35
|
+
from descry.scip.support import scip_available, get_scip_status
|
|
36
|
+
from descry.scip.cache import ScipCacheManager
|
|
37
|
+
from descry.scip.parser import ScipIndex
|
|
38
|
+
|
|
39
|
+
SCIP_SUPPORT_LOADED = True
|
|
40
|
+
except ImportError:
|
|
41
|
+
SCIP_SUPPORT_LOADED = False
|
|
42
|
+
|
|
43
|
+
def scip_available():
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
logger.debug("SCIP support not available")
|
|
47
|
+
|
|
48
|
+
# ============================================================================
|
|
49
|
+
# Non-project call filters
|
|
50
|
+
# ============================================================================
|
|
51
|
+
# Calls to these names/prefixes will never resolve to project nodes and would
|
|
52
|
+
# pollute caller/callee queries. They are split into two categories:
|
|
53
|
+
#
|
|
54
|
+
# 1. STDLIB — language built-ins and standard library (Rust std, JS/TS
|
|
55
|
+
# builtins, Python builtins)
|
|
56
|
+
# 2. LIBRARY — third-party crates, npm packages, and framework methods that
|
|
57
|
+
# are dependencies of the project but not part of its source
|
|
58
|
+
#
|
|
59
|
+
# Both sets feed into `is_non_project_call()` which is the single entry point
|
|
60
|
+
# used by parsers and the resolver.
|
|
61
|
+
# ============================================================================
|
|
62
|
+
|
|
63
|
+
# --- 1. Standard library / language built-in names ---
|
|
64
|
+
_STDLIB_NAMES = frozenset(
|
|
65
|
+
[
|
|
66
|
+
# ── Rust primitives & wrapper types ──
|
|
67
|
+
"Some",
|
|
68
|
+
"None",
|
|
69
|
+
"Ok",
|
|
70
|
+
"Err",
|
|
71
|
+
"Box",
|
|
72
|
+
"Rc",
|
|
73
|
+
"Arc",
|
|
74
|
+
"Cell",
|
|
75
|
+
"RefCell",
|
|
76
|
+
"Vec",
|
|
77
|
+
"String",
|
|
78
|
+
"HashMap",
|
|
79
|
+
"HashSet",
|
|
80
|
+
"BTreeMap",
|
|
81
|
+
"BTreeSet",
|
|
82
|
+
"Option",
|
|
83
|
+
"Result",
|
|
84
|
+
"Default",
|
|
85
|
+
"Clone",
|
|
86
|
+
"Debug",
|
|
87
|
+
"Display",
|
|
88
|
+
"Duration",
|
|
89
|
+
"Instant",
|
|
90
|
+
"SystemTime",
|
|
91
|
+
# ── Rust common methods & macros ──
|
|
92
|
+
"new",
|
|
93
|
+
"default",
|
|
94
|
+
"clone",
|
|
95
|
+
"into",
|
|
96
|
+
"from",
|
|
97
|
+
"as_ref",
|
|
98
|
+
"as_mut",
|
|
99
|
+
"unwrap",
|
|
100
|
+
"expect",
|
|
101
|
+
"unwrap_or",
|
|
102
|
+
"unwrap_or_else",
|
|
103
|
+
"unwrap_or_default",
|
|
104
|
+
"unwrap_err",
|
|
105
|
+
"ok",
|
|
106
|
+
"err",
|
|
107
|
+
"is_ok",
|
|
108
|
+
"is_err",
|
|
109
|
+
"is_some",
|
|
110
|
+
"is_none",
|
|
111
|
+
"is_some_and",
|
|
112
|
+
"map",
|
|
113
|
+
"map_err",
|
|
114
|
+
"and_then",
|
|
115
|
+
"or_else",
|
|
116
|
+
"filter",
|
|
117
|
+
"flatten",
|
|
118
|
+
"collect",
|
|
119
|
+
"iter",
|
|
120
|
+
"into_iter",
|
|
121
|
+
"iter_mut",
|
|
122
|
+
"push",
|
|
123
|
+
"pop",
|
|
124
|
+
"insert",
|
|
125
|
+
"remove",
|
|
126
|
+
"get",
|
|
127
|
+
"set",
|
|
128
|
+
"len",
|
|
129
|
+
"is_empty",
|
|
130
|
+
"contains",
|
|
131
|
+
"extend",
|
|
132
|
+
"clear",
|
|
133
|
+
"to_string",
|
|
134
|
+
"to_owned",
|
|
135
|
+
"as_str",
|
|
136
|
+
"as_bytes",
|
|
137
|
+
"format",
|
|
138
|
+
"println",
|
|
139
|
+
"print",
|
|
140
|
+
"eprintln",
|
|
141
|
+
"eprint",
|
|
142
|
+
"dbg",
|
|
143
|
+
"vec",
|
|
144
|
+
"panic",
|
|
145
|
+
"assert",
|
|
146
|
+
"assert_eq",
|
|
147
|
+
"assert_ne",
|
|
148
|
+
"debug_assert",
|
|
149
|
+
# ── Rust iterator / option / result methods ──
|
|
150
|
+
"eq",
|
|
151
|
+
"ne",
|
|
152
|
+
"cmp",
|
|
153
|
+
"partial_cmp",
|
|
154
|
+
"first",
|
|
155
|
+
"last",
|
|
156
|
+
"next",
|
|
157
|
+
"take",
|
|
158
|
+
"skip",
|
|
159
|
+
"zip",
|
|
160
|
+
"enumerate",
|
|
161
|
+
"peekable",
|
|
162
|
+
"chain",
|
|
163
|
+
"rev",
|
|
164
|
+
"cloned",
|
|
165
|
+
"copied",
|
|
166
|
+
"ok_or",
|
|
167
|
+
"ok_or_else",
|
|
168
|
+
"map_or",
|
|
169
|
+
"map_or_else",
|
|
170
|
+
"as_deref",
|
|
171
|
+
"as_deref_mut",
|
|
172
|
+
"transpose",
|
|
173
|
+
"into_inner",
|
|
174
|
+
"inner",
|
|
175
|
+
"inner_mut",
|
|
176
|
+
"get_mut",
|
|
177
|
+
"get_ref",
|
|
178
|
+
"borrow",
|
|
179
|
+
"borrow_mut",
|
|
180
|
+
"deref",
|
|
181
|
+
"deref_mut",
|
|
182
|
+
"filter_map",
|
|
183
|
+
"for_each",
|
|
184
|
+
"flat_map",
|
|
185
|
+
"fold",
|
|
186
|
+
"find_map",
|
|
187
|
+
"position",
|
|
188
|
+
"any",
|
|
189
|
+
"all",
|
|
190
|
+
"min",
|
|
191
|
+
"max",
|
|
192
|
+
"nth",
|
|
193
|
+
"sorted",
|
|
194
|
+
"copy",
|
|
195
|
+
"drop",
|
|
196
|
+
# ── Rust std additional methods ──
|
|
197
|
+
"push_str",
|
|
198
|
+
"try_into",
|
|
199
|
+
"try_from",
|
|
200
|
+
"into_bytes",
|
|
201
|
+
"floor",
|
|
202
|
+
"ceil",
|
|
203
|
+
"clamp",
|
|
204
|
+
"retain",
|
|
205
|
+
"write_all",
|
|
206
|
+
"sync_all",
|
|
207
|
+
"with_capacity",
|
|
208
|
+
"to_str",
|
|
209
|
+
"to_string_lossy",
|
|
210
|
+
"fetch_add",
|
|
211
|
+
"as_millis",
|
|
212
|
+
"as_secs",
|
|
213
|
+
"changed",
|
|
214
|
+
"sort",
|
|
215
|
+
"sort_by",
|
|
216
|
+
"sort_by_key",
|
|
217
|
+
"send",
|
|
218
|
+
"recv",
|
|
219
|
+
"try_recv",
|
|
220
|
+
"try_send",
|
|
221
|
+
"lock",
|
|
222
|
+
"try_lock",
|
|
223
|
+
"display",
|
|
224
|
+
"flush",
|
|
225
|
+
"read_to_string",
|
|
226
|
+
"write_fmt",
|
|
227
|
+
"as_path",
|
|
228
|
+
"to_path_buf",
|
|
229
|
+
"extension",
|
|
230
|
+
"file_name",
|
|
231
|
+
"parent",
|
|
232
|
+
"exists",
|
|
233
|
+
"is_file",
|
|
234
|
+
"is_dir",
|
|
235
|
+
"metadata",
|
|
236
|
+
"canonicalize",
|
|
237
|
+
"with_extension",
|
|
238
|
+
"into_string",
|
|
239
|
+
"entry",
|
|
240
|
+
"or_insert",
|
|
241
|
+
"or_insert_with",
|
|
242
|
+
"or_default",
|
|
243
|
+
"and_modify",
|
|
244
|
+
"key",
|
|
245
|
+
"value",
|
|
246
|
+
"values_mut",
|
|
247
|
+
"keys",
|
|
248
|
+
"drain",
|
|
249
|
+
"truncate",
|
|
250
|
+
"resize",
|
|
251
|
+
"capacity",
|
|
252
|
+
"reserve",
|
|
253
|
+
"shrink_to_fit",
|
|
254
|
+
"swap",
|
|
255
|
+
"swap_remove",
|
|
256
|
+
"binary_search",
|
|
257
|
+
"contains_key",
|
|
258
|
+
"remove_entry",
|
|
259
|
+
"split_at",
|
|
260
|
+
"windows",
|
|
261
|
+
"chunks",
|
|
262
|
+
"as_slice",
|
|
263
|
+
"as_mut_slice",
|
|
264
|
+
"downcast_ref",
|
|
265
|
+
"downcast_mut",
|
|
266
|
+
"type_id",
|
|
267
|
+
"extend_from_slice",
|
|
268
|
+
"copy_from_slice",
|
|
269
|
+
"saturating_sub",
|
|
270
|
+
"is_ascii_hexdigit",
|
|
271
|
+
"is_ascii",
|
|
272
|
+
"as_ptr",
|
|
273
|
+
"as_u16",
|
|
274
|
+
"sleep",
|
|
275
|
+
"abort",
|
|
276
|
+
"stderr",
|
|
277
|
+
"stdout",
|
|
278
|
+
"args",
|
|
279
|
+
# ── Rust async ──
|
|
280
|
+
"await",
|
|
281
|
+
"async",
|
|
282
|
+
"spawn",
|
|
283
|
+
"block_on",
|
|
284
|
+
# ── Rust string methods ──
|
|
285
|
+
"starts_with",
|
|
286
|
+
"ends_with",
|
|
287
|
+
"replace",
|
|
288
|
+
"replace_all",
|
|
289
|
+
"trim",
|
|
290
|
+
"trim_start",
|
|
291
|
+
"trim_end",
|
|
292
|
+
"trim_end_matches",
|
|
293
|
+
"trim_start_matches",
|
|
294
|
+
"to_lowercase",
|
|
295
|
+
"to_uppercase",
|
|
296
|
+
"chars",
|
|
297
|
+
"bytes",
|
|
298
|
+
"lines",
|
|
299
|
+
"match",
|
|
300
|
+
"matches",
|
|
301
|
+
"find",
|
|
302
|
+
"rfind",
|
|
303
|
+
"strip_prefix",
|
|
304
|
+
"strip_suffix",
|
|
305
|
+
"repeat",
|
|
306
|
+
# ── Rust I/O & channels ──
|
|
307
|
+
"read",
|
|
308
|
+
"write",
|
|
309
|
+
"close",
|
|
310
|
+
"open",
|
|
311
|
+
"fill_bytes",
|
|
312
|
+
# ── JavaScript / TypeScript built-ins ──
|
|
313
|
+
"console",
|
|
314
|
+
"log",
|
|
315
|
+
"error",
|
|
316
|
+
"warn",
|
|
317
|
+
"info",
|
|
318
|
+
"debug",
|
|
319
|
+
"setTimeout",
|
|
320
|
+
"setInterval",
|
|
321
|
+
"clearTimeout",
|
|
322
|
+
"clearInterval",
|
|
323
|
+
"Promise",
|
|
324
|
+
"resolve",
|
|
325
|
+
"reject",
|
|
326
|
+
"then",
|
|
327
|
+
"catch",
|
|
328
|
+
"finally",
|
|
329
|
+
"Array",
|
|
330
|
+
"Object",
|
|
331
|
+
"Map",
|
|
332
|
+
"Set",
|
|
333
|
+
"JSON",
|
|
334
|
+
"Math",
|
|
335
|
+
"Date",
|
|
336
|
+
"RegExp",
|
|
337
|
+
"parseInt",
|
|
338
|
+
"parseFloat",
|
|
339
|
+
"isNaN",
|
|
340
|
+
"isFinite",
|
|
341
|
+
"toString",
|
|
342
|
+
"valueOf",
|
|
343
|
+
"hasOwnProperty",
|
|
344
|
+
"push",
|
|
345
|
+
"pop",
|
|
346
|
+
"shift",
|
|
347
|
+
"unshift",
|
|
348
|
+
"slice",
|
|
349
|
+
"splice",
|
|
350
|
+
"concat",
|
|
351
|
+
"filter",
|
|
352
|
+
"map",
|
|
353
|
+
"reduce",
|
|
354
|
+
"forEach",
|
|
355
|
+
"find",
|
|
356
|
+
"findIndex",
|
|
357
|
+
"some",
|
|
358
|
+
"every",
|
|
359
|
+
"join",
|
|
360
|
+
"split",
|
|
361
|
+
"trim",
|
|
362
|
+
"toLowerCase",
|
|
363
|
+
"toUpperCase",
|
|
364
|
+
"stringify",
|
|
365
|
+
"parse",
|
|
366
|
+
"includes",
|
|
367
|
+
"indexOf",
|
|
368
|
+
"lastIndexOf",
|
|
369
|
+
"localeCompare",
|
|
370
|
+
"normalize",
|
|
371
|
+
"search",
|
|
372
|
+
"at",
|
|
373
|
+
"startsWith",
|
|
374
|
+
"endsWith",
|
|
375
|
+
"replaceAll",
|
|
376
|
+
"trimStart",
|
|
377
|
+
"trimEnd",
|
|
378
|
+
"padStart",
|
|
379
|
+
"padEnd",
|
|
380
|
+
"substring",
|
|
381
|
+
"substr",
|
|
382
|
+
"charAt",
|
|
383
|
+
"charCodeAt",
|
|
384
|
+
"codePointAt",
|
|
385
|
+
"pad_start",
|
|
386
|
+
"pad_end",
|
|
387
|
+
"strip",
|
|
388
|
+
"lstrip",
|
|
389
|
+
"rstrip",
|
|
390
|
+
# ── JS DOM / Browser ──
|
|
391
|
+
"querySelector",
|
|
392
|
+
"querySelectorAll",
|
|
393
|
+
"addEventListener",
|
|
394
|
+
"removeEventListener",
|
|
395
|
+
"scrollTo",
|
|
396
|
+
"scrollIntoView",
|
|
397
|
+
"focus",
|
|
398
|
+
"blur",
|
|
399
|
+
"preventDefault",
|
|
400
|
+
"stopPropagation",
|
|
401
|
+
"dispatchEvent",
|
|
402
|
+
"getItem",
|
|
403
|
+
"setItem",
|
|
404
|
+
"removeItem",
|
|
405
|
+
"toISOString",
|
|
406
|
+
# ── Python built-ins ──
|
|
407
|
+
"print",
|
|
408
|
+
"len",
|
|
409
|
+
"range",
|
|
410
|
+
"str",
|
|
411
|
+
"int",
|
|
412
|
+
"float",
|
|
413
|
+
"bool",
|
|
414
|
+
"list",
|
|
415
|
+
"dict",
|
|
416
|
+
"set",
|
|
417
|
+
"tuple",
|
|
418
|
+
"open",
|
|
419
|
+
"append",
|
|
420
|
+
"extend",
|
|
421
|
+
"pop",
|
|
422
|
+
"get",
|
|
423
|
+
"keys",
|
|
424
|
+
"values",
|
|
425
|
+
"items",
|
|
426
|
+
"isinstance",
|
|
427
|
+
"issubclass",
|
|
428
|
+
"hasattr",
|
|
429
|
+
"getattr",
|
|
430
|
+
"setattr",
|
|
431
|
+
"delattr",
|
|
432
|
+
"super",
|
|
433
|
+
"type",
|
|
434
|
+
"id",
|
|
435
|
+
"hash",
|
|
436
|
+
"repr",
|
|
437
|
+
"format",
|
|
438
|
+
"lower",
|
|
439
|
+
"sorted",
|
|
440
|
+
]
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
# --- 2. Third-party library / framework names ---
|
|
444
|
+
# Methods and identifiers from external dependencies that will never resolve
|
|
445
|
+
# to project nodes.
|
|
446
|
+
_LIBRARY_NAMES = frozenset(
|
|
447
|
+
[
|
|
448
|
+
# ── Diesel ORM DSL ──
|
|
449
|
+
"optional",
|
|
450
|
+
"nullable",
|
|
451
|
+
"eq_any",
|
|
452
|
+
"ne_all",
|
|
453
|
+
"load",
|
|
454
|
+
"execute",
|
|
455
|
+
"get_result",
|
|
456
|
+
"get_results",
|
|
457
|
+
"order",
|
|
458
|
+
"order_by",
|
|
459
|
+
"inner_join",
|
|
460
|
+
"left_join",
|
|
461
|
+
"group_by",
|
|
462
|
+
"having",
|
|
463
|
+
"select",
|
|
464
|
+
"count",
|
|
465
|
+
"sum",
|
|
466
|
+
"limit",
|
|
467
|
+
"offset",
|
|
468
|
+
"is_not_null",
|
|
469
|
+
"desc",
|
|
470
|
+
"asc",
|
|
471
|
+
"returning",
|
|
472
|
+
"into_boxed",
|
|
473
|
+
"or",
|
|
474
|
+
"like",
|
|
475
|
+
"not_like",
|
|
476
|
+
"between",
|
|
477
|
+
"is_null",
|
|
478
|
+
"gt",
|
|
479
|
+
"lt",
|
|
480
|
+
"ge",
|
|
481
|
+
"le",
|
|
482
|
+
"on",
|
|
483
|
+
"on_conflict",
|
|
484
|
+
# ── Chrono ──
|
|
485
|
+
"naive_utc",
|
|
486
|
+
"timestamp",
|
|
487
|
+
"timestamp_millis",
|
|
488
|
+
"now",
|
|
489
|
+
"elapsed",
|
|
490
|
+
"duration_since",
|
|
491
|
+
"to_rfc3339",
|
|
492
|
+
"and_utc",
|
|
493
|
+
"num_milliseconds",
|
|
494
|
+
# ── anyhow / error handling ──
|
|
495
|
+
"with_context",
|
|
496
|
+
"context",
|
|
497
|
+
# ── colored (terminal formatting) ──
|
|
498
|
+
"red",
|
|
499
|
+
"green",
|
|
500
|
+
"yellow",
|
|
501
|
+
"blue",
|
|
502
|
+
"cyan",
|
|
503
|
+
"magenta",
|
|
504
|
+
"white",
|
|
505
|
+
"bold",
|
|
506
|
+
"dim",
|
|
507
|
+
"underline",
|
|
508
|
+
"italic",
|
|
509
|
+
# ── tracing ──
|
|
510
|
+
"instrument",
|
|
511
|
+
"in_scope",
|
|
512
|
+
"enter",
|
|
513
|
+
"span",
|
|
514
|
+
# ── indicatif (progress bars) ──
|
|
515
|
+
"set_message",
|
|
516
|
+
"finish_with_message",
|
|
517
|
+
"inc",
|
|
518
|
+
# ── X.509 / TLS / crypto libraries ──
|
|
519
|
+
"parse_x509_certificate",
|
|
520
|
+
"serialize_pem",
|
|
521
|
+
"pem_parse",
|
|
522
|
+
"pem",
|
|
523
|
+
"validity",
|
|
524
|
+
"add_attribute",
|
|
525
|
+
"add_header",
|
|
526
|
+
"finalize",
|
|
527
|
+
# ── Redis ──
|
|
528
|
+
"smembers",
|
|
529
|
+
# ── Playwright / vitest / test frameworks ──
|
|
530
|
+
"test",
|
|
531
|
+
"describe",
|
|
532
|
+
"it",
|
|
533
|
+
"expect",
|
|
534
|
+
"assert_status",
|
|
535
|
+
"assert_status_ok",
|
|
536
|
+
"toBeVisible",
|
|
537
|
+
"toHaveText",
|
|
538
|
+
"toBe",
|
|
539
|
+
"toEqual",
|
|
540
|
+
"toContain",
|
|
541
|
+
"toHaveLength",
|
|
542
|
+
"toBeNull",
|
|
543
|
+
"toBeUndefined",
|
|
544
|
+
"toBeTruthy",
|
|
545
|
+
"toBeFalsy",
|
|
546
|
+
"toBeInTheDocument",
|
|
547
|
+
"toHaveURL",
|
|
548
|
+
"toHaveClass",
|
|
549
|
+
"toHaveAttribute",
|
|
550
|
+
"toHaveCount",
|
|
551
|
+
"toHaveValue",
|
|
552
|
+
"toBeChecked",
|
|
553
|
+
"toBeDisabled",
|
|
554
|
+
"toBeEnabled",
|
|
555
|
+
"toBeHidden",
|
|
556
|
+
"toMatchObject",
|
|
557
|
+
"toThrow",
|
|
558
|
+
"toHaveBeenCalled",
|
|
559
|
+
"click",
|
|
560
|
+
"fill",
|
|
561
|
+
"locator",
|
|
562
|
+
"getByRole",
|
|
563
|
+
"getByText",
|
|
564
|
+
"getByTestId",
|
|
565
|
+
"waitFor",
|
|
566
|
+
"waitForTimeout",
|
|
567
|
+
"waitForSelector",
|
|
568
|
+
"waitForLoadState",
|
|
569
|
+
"waitForURL",
|
|
570
|
+
"selectOption",
|
|
571
|
+
"goto",
|
|
572
|
+
"beforeEach",
|
|
573
|
+
"afterEach",
|
|
574
|
+
"before",
|
|
575
|
+
"after",
|
|
576
|
+
"black_box",
|
|
577
|
+
# ── Svelte / SvelteKit ──
|
|
578
|
+
"json",
|
|
579
|
+
"derived",
|
|
580
|
+
"style",
|
|
581
|
+
# ── clap (CLI argument parsing) ──
|
|
582
|
+
"arg",
|
|
583
|
+
# ── misc library methods ──
|
|
584
|
+
"as_array",
|
|
585
|
+
"headers",
|
|
586
|
+
"contents",
|
|
587
|
+
"extensions",
|
|
588
|
+
"attributes",
|
|
589
|
+
# ── TS/Python external module names (used as receiver patterns) ──
|
|
590
|
+
"authenticatedPage",
|
|
591
|
+
"container",
|
|
592
|
+
"page",
|
|
593
|
+
"pptx",
|
|
594
|
+
"mcp",
|
|
595
|
+
"re",
|
|
596
|
+
"sys",
|
|
597
|
+
"os",
|
|
598
|
+
"pathlib",
|
|
599
|
+
"subprocess",
|
|
600
|
+
"shutil",
|
|
601
|
+
"glob",
|
|
602
|
+
"yaml",
|
|
603
|
+
"toml",
|
|
604
|
+
# ── Python stdlib module names (bare) ──
|
|
605
|
+
"json",
|
|
606
|
+
"time",
|
|
607
|
+
"datetime",
|
|
608
|
+
"asyncio",
|
|
609
|
+
"collections",
|
|
610
|
+
"hashlib",
|
|
611
|
+
"hmac",
|
|
612
|
+
"logging",
|
|
613
|
+
"argparse",
|
|
614
|
+
"threading",
|
|
615
|
+
"multiprocessing",
|
|
616
|
+
"functools",
|
|
617
|
+
"itertools",
|
|
618
|
+
"operator",
|
|
619
|
+
"inspect",
|
|
620
|
+
"typing",
|
|
621
|
+
"dataclasses",
|
|
622
|
+
"enum",
|
|
623
|
+
"abc",
|
|
624
|
+
"contextlib",
|
|
625
|
+
"tempfile",
|
|
626
|
+
"shlex",
|
|
627
|
+
"signal",
|
|
628
|
+
"socket",
|
|
629
|
+
"select",
|
|
630
|
+
"urllib",
|
|
631
|
+
"http",
|
|
632
|
+
"base64",
|
|
633
|
+
"secrets",
|
|
634
|
+
"random",
|
|
635
|
+
"math",
|
|
636
|
+
"statistics",
|
|
637
|
+
"struct",
|
|
638
|
+
"copy",
|
|
639
|
+
"stat",
|
|
640
|
+
"platform",
|
|
641
|
+
"errno",
|
|
642
|
+
"tomllib",
|
|
643
|
+
"tomli",
|
|
644
|
+
"io",
|
|
645
|
+
"weakref",
|
|
646
|
+
"textwrap",
|
|
647
|
+
"traceback",
|
|
648
|
+
"warnings",
|
|
649
|
+
"unittest",
|
|
650
|
+
"email",
|
|
651
|
+
"csv",
|
|
652
|
+
"gzip",
|
|
653
|
+
"zipfile",
|
|
654
|
+
"tarfile",
|
|
655
|
+
"sqlite3",
|
|
656
|
+
"importlib",
|
|
657
|
+
# ── Python builtin exceptions (never project symbols) ──
|
|
658
|
+
"Exception",
|
|
659
|
+
"BaseException",
|
|
660
|
+
"ValueError",
|
|
661
|
+
"TypeError",
|
|
662
|
+
"KeyError",
|
|
663
|
+
"IndexError",
|
|
664
|
+
"AttributeError",
|
|
665
|
+
"RuntimeError",
|
|
666
|
+
"NotImplementedError",
|
|
667
|
+
"StopIteration",
|
|
668
|
+
"StopAsyncIteration",
|
|
669
|
+
"FileNotFoundError",
|
|
670
|
+
"FileExistsError",
|
|
671
|
+
"IsADirectoryError",
|
|
672
|
+
"NotADirectoryError",
|
|
673
|
+
"PermissionError",
|
|
674
|
+
"TimeoutError",
|
|
675
|
+
"OSError",
|
|
676
|
+
"IOError",
|
|
677
|
+
"OverflowError",
|
|
678
|
+
"ZeroDivisionError",
|
|
679
|
+
"ArithmeticError",
|
|
680
|
+
"AssertionError",
|
|
681
|
+
"LookupError",
|
|
682
|
+
"ImportError",
|
|
683
|
+
"ModuleNotFoundError",
|
|
684
|
+
"NameError",
|
|
685
|
+
"UnboundLocalError",
|
|
686
|
+
"UnicodeDecodeError",
|
|
687
|
+
"UnicodeEncodeError",
|
|
688
|
+
"UnicodeError",
|
|
689
|
+
"SystemExit",
|
|
690
|
+
"KeyboardInterrupt",
|
|
691
|
+
"GeneratorExit",
|
|
692
|
+
"RecursionError",
|
|
693
|
+
"BrokenPipeError",
|
|
694
|
+
"ConnectionError",
|
|
695
|
+
"ConnectionResetError",
|
|
696
|
+
"ConnectionAbortedError",
|
|
697
|
+
"ConnectionRefusedError",
|
|
698
|
+
"BlockingIOError",
|
|
699
|
+
"InterruptedError",
|
|
700
|
+
"ChildProcessError",
|
|
701
|
+
"ProcessLookupError",
|
|
702
|
+
# ── Python builtin types / constructors ──
|
|
703
|
+
"dict",
|
|
704
|
+
"list",
|
|
705
|
+
"set",
|
|
706
|
+
"frozenset",
|
|
707
|
+
"tuple",
|
|
708
|
+
"str",
|
|
709
|
+
"bytes",
|
|
710
|
+
"bytearray",
|
|
711
|
+
"int",
|
|
712
|
+
"float",
|
|
713
|
+
"bool",
|
|
714
|
+
"complex",
|
|
715
|
+
"range",
|
|
716
|
+
"slice",
|
|
717
|
+
"enumerate",
|
|
718
|
+
"zip",
|
|
719
|
+
"map",
|
|
720
|
+
"filter",
|
|
721
|
+
"reversed",
|
|
722
|
+
"sorted",
|
|
723
|
+
"type",
|
|
724
|
+
"object",
|
|
725
|
+
"super",
|
|
726
|
+
"property",
|
|
727
|
+
"staticmethod",
|
|
728
|
+
"classmethod",
|
|
729
|
+
# ── Python builtin functions ──
|
|
730
|
+
"print",
|
|
731
|
+
"repr",
|
|
732
|
+
"ascii",
|
|
733
|
+
"bin",
|
|
734
|
+
"hex",
|
|
735
|
+
"oct",
|
|
736
|
+
"chr",
|
|
737
|
+
"ord",
|
|
738
|
+
"len",
|
|
739
|
+
"abs",
|
|
740
|
+
"min",
|
|
741
|
+
"max",
|
|
742
|
+
"sum",
|
|
743
|
+
"round",
|
|
744
|
+
"pow",
|
|
745
|
+
"divmod",
|
|
746
|
+
"all",
|
|
747
|
+
"any",
|
|
748
|
+
"next",
|
|
749
|
+
"iter",
|
|
750
|
+
"hash",
|
|
751
|
+
"id",
|
|
752
|
+
"vars",
|
|
753
|
+
"dir",
|
|
754
|
+
"callable",
|
|
755
|
+
"hasattr",
|
|
756
|
+
"getattr",
|
|
757
|
+
"setattr",
|
|
758
|
+
"delattr",
|
|
759
|
+
"isinstance",
|
|
760
|
+
"issubclass",
|
|
761
|
+
"open",
|
|
762
|
+
"input",
|
|
763
|
+
"format",
|
|
764
|
+
"globals",
|
|
765
|
+
"locals",
|
|
766
|
+
"eval",
|
|
767
|
+
"exec",
|
|
768
|
+
"compile",
|
|
769
|
+
"breakpoint",
|
|
770
|
+
"help",
|
|
771
|
+
# ── Python stdlib / builtin method names (no reliable project
|
|
772
|
+
# symbol collision) — these are method calls on stdlib-typed
|
|
773
|
+
# receivers that never resolve to project definitions.
|
|
774
|
+
"write_text",
|
|
775
|
+
"read_text",
|
|
776
|
+
"write_bytes",
|
|
777
|
+
"read_bytes",
|
|
778
|
+
"as_posix",
|
|
779
|
+
"as_uri",
|
|
780
|
+
"mkdir",
|
|
781
|
+
"rmdir",
|
|
782
|
+
"touch",
|
|
783
|
+
"rename",
|
|
784
|
+
"replace",
|
|
785
|
+
"hexdigest",
|
|
786
|
+
"digest",
|
|
787
|
+
"update",
|
|
788
|
+
"startswith",
|
|
789
|
+
"endswith",
|
|
790
|
+
"encode",
|
|
791
|
+
"decode",
|
|
792
|
+
"upper",
|
|
793
|
+
"lower",
|
|
794
|
+
"title",
|
|
795
|
+
"capitalize",
|
|
796
|
+
"casefold",
|
|
797
|
+
"strip",
|
|
798
|
+
"lstrip",
|
|
799
|
+
"rstrip",
|
|
800
|
+
"split",
|
|
801
|
+
"rsplit",
|
|
802
|
+
"splitlines",
|
|
803
|
+
"join",
|
|
804
|
+
"removeprefix",
|
|
805
|
+
"removesuffix",
|
|
806
|
+
"append",
|
|
807
|
+
"extend",
|
|
808
|
+
"insert",
|
|
809
|
+
"pop",
|
|
810
|
+
"remove",
|
|
811
|
+
"reverse",
|
|
812
|
+
"index",
|
|
813
|
+
"count",
|
|
814
|
+
"add",
|
|
815
|
+
"discard",
|
|
816
|
+
"clear",
|
|
817
|
+
"copy",
|
|
818
|
+
"keys",
|
|
819
|
+
"values",
|
|
820
|
+
"items",
|
|
821
|
+
"get",
|
|
822
|
+
"setdefault",
|
|
823
|
+
"popitem",
|
|
824
|
+
"find",
|
|
825
|
+
"rfind",
|
|
826
|
+
"rindex",
|
|
827
|
+
"zfill",
|
|
828
|
+
"center",
|
|
829
|
+
"ljust",
|
|
830
|
+
"rjust",
|
|
831
|
+
"expandtabs",
|
|
832
|
+
"partition",
|
|
833
|
+
"rpartition",
|
|
834
|
+
"translate",
|
|
835
|
+
"maketrans",
|
|
836
|
+
"format_map",
|
|
837
|
+
"is_dir",
|
|
838
|
+
"is_file",
|
|
839
|
+
"is_symlink",
|
|
840
|
+
"is_absolute",
|
|
841
|
+
"exists",
|
|
842
|
+
"stat",
|
|
843
|
+
"lstat",
|
|
844
|
+
"chmod",
|
|
845
|
+
"unlink",
|
|
846
|
+
"glob",
|
|
847
|
+
"rglob",
|
|
848
|
+
"iterdir",
|
|
849
|
+
"resolve",
|
|
850
|
+
"relative_to",
|
|
851
|
+
"with_suffix",
|
|
852
|
+
"with_stem",
|
|
853
|
+
"with_name",
|
|
854
|
+
"joinpath",
|
|
855
|
+
"samefile",
|
|
856
|
+
"cwd",
|
|
857
|
+
"home",
|
|
858
|
+
"seek",
|
|
859
|
+
"tell",
|
|
860
|
+
"flush",
|
|
861
|
+
"close",
|
|
862
|
+
"readline",
|
|
863
|
+
"readlines",
|
|
864
|
+
"writelines",
|
|
865
|
+
"truncate",
|
|
866
|
+
# Common method-chain intermediates.
|
|
867
|
+
"reshape",
|
|
868
|
+
"astype",
|
|
869
|
+
"cpu",
|
|
870
|
+
"gpu",
|
|
871
|
+
"detach",
|
|
872
|
+
# More stdlib method names
|
|
873
|
+
"isupper",
|
|
874
|
+
"islower",
|
|
875
|
+
"isdigit",
|
|
876
|
+
"isnumeric",
|
|
877
|
+
"isalpha",
|
|
878
|
+
"isalnum",
|
|
879
|
+
"isspace",
|
|
880
|
+
"isidentifier",
|
|
881
|
+
"isprintable",
|
|
882
|
+
"istitle",
|
|
883
|
+
"isascii",
|
|
884
|
+
"issubset",
|
|
885
|
+
"issuperset",
|
|
886
|
+
"symmetric_difference",
|
|
887
|
+
"intersection",
|
|
888
|
+
"union",
|
|
889
|
+
"difference",
|
|
890
|
+
"setLevel",
|
|
891
|
+
"getLogger",
|
|
892
|
+
"getLevelName",
|
|
893
|
+
"basicConfig",
|
|
894
|
+
"cache_clear",
|
|
895
|
+
"cache_info",
|
|
896
|
+
"popleft",
|
|
897
|
+
"popright",
|
|
898
|
+
"appendleft",
|
|
899
|
+
"group",
|
|
900
|
+
"groups",
|
|
901
|
+
"groupdict",
|
|
902
|
+
"start",
|
|
903
|
+
"end",
|
|
904
|
+
"span",
|
|
905
|
+
"submit",
|
|
906
|
+
"cancel",
|
|
907
|
+
"done",
|
|
908
|
+
"result",
|
|
909
|
+
"as_completed",
|
|
910
|
+
"gather",
|
|
911
|
+
"wait",
|
|
912
|
+
"wait_for",
|
|
913
|
+
"create_task",
|
|
914
|
+
"ensure_future",
|
|
915
|
+
"run_until_complete",
|
|
916
|
+
"close",
|
|
917
|
+
"run",
|
|
918
|
+
"new_event_loop",
|
|
919
|
+
"asynccontextmanager",
|
|
920
|
+
"contextmanager",
|
|
921
|
+
"wraps",
|
|
922
|
+
"partial",
|
|
923
|
+
"reduce",
|
|
924
|
+
"lru_cache",
|
|
925
|
+
"cache",
|
|
926
|
+
"total_ordering",
|
|
927
|
+
"singledispatch",
|
|
928
|
+
"TYPE_CHECKING",
|
|
929
|
+
"cast",
|
|
930
|
+
"Any",
|
|
931
|
+
"Optional",
|
|
932
|
+
"Union",
|
|
933
|
+
"List",
|
|
934
|
+
"Dict",
|
|
935
|
+
"Set",
|
|
936
|
+
"Tuple",
|
|
937
|
+
"Callable",
|
|
938
|
+
"Iterator",
|
|
939
|
+
"Iterable",
|
|
940
|
+
"AsyncIterator",
|
|
941
|
+
"AsyncIterable",
|
|
942
|
+
"Awaitable",
|
|
943
|
+
"Coroutine",
|
|
944
|
+
"Generator",
|
|
945
|
+
"AsyncGenerator",
|
|
946
|
+
]
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
# Combined filter used by is_non_project_call()
|
|
950
|
+
STDLIB_FILTER = _STDLIB_NAMES | _LIBRARY_NAMES
|
|
951
|
+
|
|
952
|
+
# --- Prefix-based filters ---
|
|
953
|
+
# Qualified name prefixes that indicate non-project calls.
|
|
954
|
+
|
|
955
|
+
_STDLIB_PREFIXES = (
|
|
956
|
+
# ── Rust standard library ──
|
|
957
|
+
"std::",
|
|
958
|
+
"core::",
|
|
959
|
+
"alloc::",
|
|
960
|
+
"fs::",
|
|
961
|
+
"io::",
|
|
962
|
+
"env::",
|
|
963
|
+
"net::",
|
|
964
|
+
"path::",
|
|
965
|
+
# ── Rust std types (qualified calls) ──
|
|
966
|
+
"Duration::",
|
|
967
|
+
"Instant::",
|
|
968
|
+
"SystemTime::",
|
|
969
|
+
"Vec::",
|
|
970
|
+
"String::",
|
|
971
|
+
"Arc::",
|
|
972
|
+
"Rc::",
|
|
973
|
+
"Box::",
|
|
974
|
+
"Cell::",
|
|
975
|
+
"RefCell::",
|
|
976
|
+
"HashMap::",
|
|
977
|
+
"HashSet::",
|
|
978
|
+
"BTreeMap::",
|
|
979
|
+
"BTreeSet::",
|
|
980
|
+
"Option::",
|
|
981
|
+
"Result::",
|
|
982
|
+
"Path::",
|
|
983
|
+
"PathBuf::",
|
|
984
|
+
"Ok::<",
|
|
985
|
+
"Err::<",
|
|
986
|
+
"Cow::",
|
|
987
|
+
"Mutex::",
|
|
988
|
+
"RwLock::",
|
|
989
|
+
"OsStr::",
|
|
990
|
+
"OsString::",
|
|
991
|
+
"NonZeroU",
|
|
992
|
+
"PhantomData::",
|
|
993
|
+
"Command::",
|
|
994
|
+
"Stdio::",
|
|
995
|
+
"Ordering::",
|
|
996
|
+
"AtomicBool::",
|
|
997
|
+
"AtomicUsize::",
|
|
998
|
+
"Sender::",
|
|
999
|
+
"Receiver::",
|
|
1000
|
+
"Channel::",
|
|
1001
|
+
"TcpListener::",
|
|
1002
|
+
"TcpStream::",
|
|
1003
|
+
"SocketAddr::",
|
|
1004
|
+
"UdpSocket::",
|
|
1005
|
+
"IpAddr::",
|
|
1006
|
+
"OpenOptions::",
|
|
1007
|
+
# NOTE: "Self::" was removed here — Self::method() calls are project calls.
|
|
1008
|
+
# Self::new/default/from/into are caught by STDLIB_FILTER via the last_part check.
|
|
1009
|
+
# The qualified-call shortcut at line ~877 has a "Self" exclusion to ensure
|
|
1010
|
+
# Self:: calls fall through to the last_part filter instead of early-exiting.
|
|
1011
|
+
# ── JavaScript / TypeScript built-in objects ──
|
|
1012
|
+
"console.",
|
|
1013
|
+
"Math.",
|
|
1014
|
+
"JSON.",
|
|
1015
|
+
"Object.",
|
|
1016
|
+
"Array.",
|
|
1017
|
+
"Promise.",
|
|
1018
|
+
"window.",
|
|
1019
|
+
"document.",
|
|
1020
|
+
"navigator.",
|
|
1021
|
+
"sessionStorage.",
|
|
1022
|
+
"localStorage.",
|
|
1023
|
+
# ── Python stdlib modules ──
|
|
1024
|
+
"os.",
|
|
1025
|
+
"sys.",
|
|
1026
|
+
"re.",
|
|
1027
|
+
"pathlib.",
|
|
1028
|
+
"subprocess.",
|
|
1029
|
+
"shutil.",
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
_LIBRARY_PREFIXES = (
|
|
1033
|
+
# ── Rust async runtimes ──
|
|
1034
|
+
"tokio::",
|
|
1035
|
+
"async_std::",
|
|
1036
|
+
# ── Serialization ──
|
|
1037
|
+
"serde::",
|
|
1038
|
+
"serde_json::",
|
|
1039
|
+
# ── Logging / tracing ──
|
|
1040
|
+
"tracing::",
|
|
1041
|
+
"log::",
|
|
1042
|
+
# ── Web frameworks ──
|
|
1043
|
+
"axum::",
|
|
1044
|
+
"axum_extra::",
|
|
1045
|
+
"tower::",
|
|
1046
|
+
"tower_http::",
|
|
1047
|
+
"hyper::",
|
|
1048
|
+
"http::",
|
|
1049
|
+
"reqwest::",
|
|
1050
|
+
# ── gRPC ──
|
|
1051
|
+
"tonic::",
|
|
1052
|
+
"prost::",
|
|
1053
|
+
# ── Database ──
|
|
1054
|
+
"diesel::",
|
|
1055
|
+
"schema::",
|
|
1056
|
+
"dsl::",
|
|
1057
|
+
# ── Error handling ──
|
|
1058
|
+
"anyhow::",
|
|
1059
|
+
"thiserror::",
|
|
1060
|
+
# ── Crypto / TLS ──
|
|
1061
|
+
"ring::",
|
|
1062
|
+
"rcgen::",
|
|
1063
|
+
"openssl::",
|
|
1064
|
+
"rustls::",
|
|
1065
|
+
"pem::",
|
|
1066
|
+
"x509_parser::",
|
|
1067
|
+
# ── Messaging ──
|
|
1068
|
+
"lapin::",
|
|
1069
|
+
# ── Encoding ──
|
|
1070
|
+
"base64::",
|
|
1071
|
+
"hex::",
|
|
1072
|
+
# ── Misc crates ──
|
|
1073
|
+
"uuid::",
|
|
1074
|
+
"chrono::",
|
|
1075
|
+
"rand::",
|
|
1076
|
+
"regex::",
|
|
1077
|
+
"url::",
|
|
1078
|
+
"futures::",
|
|
1079
|
+
"clap::",
|
|
1080
|
+
"config::",
|
|
1081
|
+
"figment::",
|
|
1082
|
+
"tempfile::",
|
|
1083
|
+
"walkdir::",
|
|
1084
|
+
"notify::",
|
|
1085
|
+
"crossbeam::",
|
|
1086
|
+
"parking_lot::",
|
|
1087
|
+
"dashmap::",
|
|
1088
|
+
"once_cell::",
|
|
1089
|
+
"bytes::",
|
|
1090
|
+
"pin_project::",
|
|
1091
|
+
"git2::",
|
|
1092
|
+
"testcontainers::",
|
|
1093
|
+
"dirs::",
|
|
1094
|
+
# ── External type prefixes ──
|
|
1095
|
+
"Status::",
|
|
1096
|
+
"StatusCode::",
|
|
1097
|
+
"HeaderValue::",
|
|
1098
|
+
"HeaderName::",
|
|
1099
|
+
"Method::",
|
|
1100
|
+
"TempDir::",
|
|
1101
|
+
"Regex::",
|
|
1102
|
+
"Uuid::",
|
|
1103
|
+
"DateTime::",
|
|
1104
|
+
"NaiveDate::",
|
|
1105
|
+
"NaiveDateTime::",
|
|
1106
|
+
"Utc::",
|
|
1107
|
+
"Router::",
|
|
1108
|
+
"KeyPair::",
|
|
1109
|
+
"Version::",
|
|
1110
|
+
"Endpoint::",
|
|
1111
|
+
"ProgressStyle::",
|
|
1112
|
+
"X509Certificate::",
|
|
1113
|
+
"Certificate::",
|
|
1114
|
+
"GenericImage::",
|
|
1115
|
+
"EnvFilter::",
|
|
1116
|
+
"Issuer::",
|
|
1117
|
+
"ClientTlsConfig::",
|
|
1118
|
+
"FieldValue::",
|
|
1119
|
+
"ProtoDeploymentSourceType::",
|
|
1120
|
+
"Identity::",
|
|
1121
|
+
"SetResponseHeaderLayer::",
|
|
1122
|
+
"EncodingKey::",
|
|
1123
|
+
"Confirm::",
|
|
1124
|
+
"Input::",
|
|
1125
|
+
"Password::",
|
|
1126
|
+
"query.load::",
|
|
1127
|
+
# ── Test receiver patterns ──
|
|
1128
|
+
"authenticatedPage.",
|
|
1129
|
+
"page.",
|
|
1130
|
+
"response.",
|
|
1131
|
+
"cert.",
|
|
1132
|
+
"conn.",
|
|
1133
|
+
"match.",
|
|
1134
|
+
"stripped.",
|
|
1135
|
+
"mcp.",
|
|
1136
|
+
"pptx.",
|
|
1137
|
+
"logger.",
|
|
1138
|
+
"spinner.",
|
|
1139
|
+
"hasher.",
|
|
1140
|
+
"pb.",
|
|
1141
|
+
"np.",
|
|
1142
|
+
"node_id.",
|
|
1143
|
+
"line.",
|
|
1144
|
+
"table.",
|
|
1145
|
+
"group.",
|
|
1146
|
+
"c.",
|
|
1147
|
+
"prs.",
|
|
1148
|
+
"subparsers.",
|
|
1149
|
+
"pytest.",
|
|
1150
|
+
# ── Python stdlib modules (dotted call form: json.dumps, asyncio.Lock) ──
|
|
1151
|
+
"json.",
|
|
1152
|
+
"time.",
|
|
1153
|
+
"datetime.",
|
|
1154
|
+
"asyncio.",
|
|
1155
|
+
"collections.",
|
|
1156
|
+
"hashlib.",
|
|
1157
|
+
"hmac.",
|
|
1158
|
+
"logging.",
|
|
1159
|
+
"argparse.",
|
|
1160
|
+
"subprocess.",
|
|
1161
|
+
"threading.",
|
|
1162
|
+
"multiprocessing.",
|
|
1163
|
+
"functools.",
|
|
1164
|
+
"itertools.",
|
|
1165
|
+
"operator.",
|
|
1166
|
+
"inspect.",
|
|
1167
|
+
"typing.",
|
|
1168
|
+
"dataclasses.",
|
|
1169
|
+
"enum.",
|
|
1170
|
+
"abc.",
|
|
1171
|
+
"contextlib.",
|
|
1172
|
+
"tempfile.",
|
|
1173
|
+
"shlex.",
|
|
1174
|
+
"signal.",
|
|
1175
|
+
"socket.",
|
|
1176
|
+
"select.",
|
|
1177
|
+
"urllib.",
|
|
1178
|
+
"http.",
|
|
1179
|
+
"base64.",
|
|
1180
|
+
"secrets.",
|
|
1181
|
+
"random.",
|
|
1182
|
+
"math.",
|
|
1183
|
+
"statistics.",
|
|
1184
|
+
"struct.",
|
|
1185
|
+
"copy.",
|
|
1186
|
+
"stat.",
|
|
1187
|
+
"platform.",
|
|
1188
|
+
"errno.",
|
|
1189
|
+
"tomllib.",
|
|
1190
|
+
"tomli.",
|
|
1191
|
+
"io.",
|
|
1192
|
+
"weakref.",
|
|
1193
|
+
"textwrap.",
|
|
1194
|
+
"traceback.",
|
|
1195
|
+
"warnings.",
|
|
1196
|
+
"unittest.",
|
|
1197
|
+
"email.",
|
|
1198
|
+
"csv.",
|
|
1199
|
+
"gzip.",
|
|
1200
|
+
"zipfile.",
|
|
1201
|
+
"tarfile.",
|
|
1202
|
+
"sqlite3.",
|
|
1203
|
+
"importlib.",
|
|
1204
|
+
"pkgutil.",
|
|
1205
|
+
# ── Python third-party libraries used by descry's deps ──
|
|
1206
|
+
"starlette.",
|
|
1207
|
+
"uvicorn.",
|
|
1208
|
+
"fastapi.",
|
|
1209
|
+
"pydantic.",
|
|
1210
|
+
"httpx.",
|
|
1211
|
+
"requests.",
|
|
1212
|
+
"aiohttp.",
|
|
1213
|
+
"numpy.",
|
|
1214
|
+
"scipy.",
|
|
1215
|
+
"pandas.",
|
|
1216
|
+
"torch.",
|
|
1217
|
+
"sentence_transformers.",
|
|
1218
|
+
"transformers.",
|
|
1219
|
+
"tokenizers.",
|
|
1220
|
+
"huggingface_hub.",
|
|
1221
|
+
"sklearn.",
|
|
1222
|
+
"tqdm.",
|
|
1223
|
+
"rich.",
|
|
1224
|
+
"click.",
|
|
1225
|
+
"typer.",
|
|
1226
|
+
"jinja2.",
|
|
1227
|
+
"yaml.",
|
|
1228
|
+
"toml.",
|
|
1229
|
+
"tomli.",
|
|
1230
|
+
"mcp.",
|
|
1231
|
+
"tree_sitter.",
|
|
1232
|
+
"tree_sitter_typescript.",
|
|
1233
|
+
"tree_sitter_javascript.",
|
|
1234
|
+
"concurrent.",
|
|
1235
|
+
"concurrent.futures.",
|
|
1236
|
+
"ast.",
|
|
1237
|
+
"fcntl.",
|
|
1238
|
+
"_fcntl.",
|
|
1239
|
+
"monkeypatch.",
|
|
1240
|
+
"psutil.",
|
|
1241
|
+
"scip_pb2.",
|
|
1242
|
+
# ── Common Python local-variable receiver patterns ──
|
|
1243
|
+
# ArgumentParser / subparser common receiver names.
|
|
1244
|
+
"parser.",
|
|
1245
|
+
"sub.",
|
|
1246
|
+
"subparsers.",
|
|
1247
|
+
"subcommands.",
|
|
1248
|
+
"args.",
|
|
1249
|
+
"ns.",
|
|
1250
|
+
"namespace.",
|
|
1251
|
+
# Path / file / graph receiver names commonly used across descry code.
|
|
1252
|
+
"path_obj.",
|
|
1253
|
+
"p.",
|
|
1254
|
+
"cfg.",
|
|
1255
|
+
"config.",
|
|
1256
|
+
"gp.",
|
|
1257
|
+
"fd.",
|
|
1258
|
+
"fh.",
|
|
1259
|
+
"result.",
|
|
1260
|
+
"proc.",
|
|
1261
|
+
"res.",
|
|
1262
|
+
"req.",
|
|
1263
|
+
"resp.",
|
|
1264
|
+
"request.",
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
# Combined tuple used by is_non_project_call()
|
|
1268
|
+
STDLIB_PREFIXES = _STDLIB_PREFIXES + _LIBRARY_PREFIXES
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def build_line_to_context_map(nodes: list, file_id: str) -> dict:
|
|
1272
|
+
"""Build a mapping from line numbers to containing function/method context.
|
|
1273
|
+
|
|
1274
|
+
Uses span-size ordering to ensure innermost (most specific) context wins.
|
|
1275
|
+
For nested functions, the inner function should be the context for its lines,
|
|
1276
|
+
not the outer function.
|
|
1277
|
+
|
|
1278
|
+
Args:
|
|
1279
|
+
nodes: List of graph nodes
|
|
1280
|
+
file_id: File ID prefix (e.g., "FILE:path/to/file.rs")
|
|
1281
|
+
|
|
1282
|
+
Returns:
|
|
1283
|
+
Dict mapping line number -> node ID of containing function/method
|
|
1284
|
+
"""
|
|
1285
|
+
# Collect all function/method spans in this file
|
|
1286
|
+
spans = []
|
|
1287
|
+
for node in nodes:
|
|
1288
|
+
if node["id"].startswith(file_id + "::"):
|
|
1289
|
+
if node["type"] in ("Function", "Method"):
|
|
1290
|
+
start = node["metadata"].get("lineno", 0)
|
|
1291
|
+
end = node["metadata"].get("end_lineno", start + 100)
|
|
1292
|
+
span_size = end - start
|
|
1293
|
+
spans.append((span_size, start, end, node["id"]))
|
|
1294
|
+
|
|
1295
|
+
# Sort by span size DESCENDING (largest first)
|
|
1296
|
+
# This way, smaller (inner) spans override larger (outer) spans
|
|
1297
|
+
spans.sort(key=lambda x: -x[0])
|
|
1298
|
+
|
|
1299
|
+
# Build the mapping - last write wins, so inner functions override
|
|
1300
|
+
line_to_context = {}
|
|
1301
|
+
for _, start, end, node_id in spans:
|
|
1302
|
+
for ln in range(start, end + 1):
|
|
1303
|
+
line_to_context[ln] = node_id
|
|
1304
|
+
|
|
1305
|
+
return line_to_context
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def is_non_project_call(callee: str) -> bool:
|
|
1309
|
+
"""Check if a callee is a non-project call (stdlib or third-party library) that should be filtered."""
|
|
1310
|
+
# Direct match
|
|
1311
|
+
if callee in STDLIB_FILTER:
|
|
1312
|
+
return True
|
|
1313
|
+
|
|
1314
|
+
# Extract last component for method calls like "foo.bar.unwrap"
|
|
1315
|
+
last_part = callee.split(".")[-1].split("::")[-1]
|
|
1316
|
+
|
|
1317
|
+
# Diesel DSL column access pattern: table_name::column.method (e.g., targets::id.eq)
|
|
1318
|
+
# These are ORM DSL calls that never resolve to project functions
|
|
1319
|
+
if "::" in callee and "." in callee:
|
|
1320
|
+
parts = callee.split("::")
|
|
1321
|
+
if len(parts) == 2 and "." in parts[1]:
|
|
1322
|
+
col_part = parts[1].split(".")[0]
|
|
1323
|
+
# If both table and column are lowercase identifiers, it's Diesel DSL
|
|
1324
|
+
if parts[0].islower() and col_part.islower():
|
|
1325
|
+
return True
|
|
1326
|
+
|
|
1327
|
+
# For qualified calls like Type::new, only filter if the type is also stdlib
|
|
1328
|
+
# This allows AppState::new but filters Vec::new, HashMap::new
|
|
1329
|
+
if "::" in callee:
|
|
1330
|
+
type_part = callee.split("::")[-2] if "::" in callee else ""
|
|
1331
|
+
# If qualified with a custom type (not stdlib and not Self), don't filter.
|
|
1332
|
+
# Self:: must fall through to the last_part check so that Self::new,
|
|
1333
|
+
# Self::default, Self::from etc. are correctly filtered by STDLIB_FILTER,
|
|
1334
|
+
# while Self::project_method passes through as a project call.
|
|
1335
|
+
if (
|
|
1336
|
+
type_part
|
|
1337
|
+
and type_part != "Self"
|
|
1338
|
+
and type_part not in STDLIB_FILTER
|
|
1339
|
+
and not any(callee.startswith(p) for p in STDLIB_PREFIXES)
|
|
1340
|
+
):
|
|
1341
|
+
return False
|
|
1342
|
+
|
|
1343
|
+
if last_part in STDLIB_FILTER:
|
|
1344
|
+
return True
|
|
1345
|
+
|
|
1346
|
+
# Prefix match for qualified names
|
|
1347
|
+
for prefix in STDLIB_PREFIXES:
|
|
1348
|
+
if callee.startswith(prefix):
|
|
1349
|
+
return True
|
|
1350
|
+
|
|
1351
|
+
return False
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
# --- Schema Definition ---
|
|
1355
|
+
# Nodes: { "id": str, "type": str, "metadata": dict }
|
|
1356
|
+
# Edges: { "source": str, "target": str, "relation": str }
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
class SymbolTable:
|
|
1360
|
+
def __init__(self):
|
|
1361
|
+
# Maps local_alias -> fully_qualified_name
|
|
1362
|
+
self.imports = {}
|
|
1363
|
+
|
|
1364
|
+
def add_import(self, name, module=None, alias=None):
|
|
1365
|
+
key = alias if alias else name
|
|
1366
|
+
if module:
|
|
1367
|
+
value = f"{module}.{name}"
|
|
1368
|
+
else:
|
|
1369
|
+
value = name
|
|
1370
|
+
self.imports[key] = value
|
|
1371
|
+
|
|
1372
|
+
def resolve(self, name):
|
|
1373
|
+
parts = name.split(".")
|
|
1374
|
+
root = parts[0]
|
|
1375
|
+
if root in self.imports:
|
|
1376
|
+
resolved_root = self.imports[root]
|
|
1377
|
+
if len(parts) > 1:
|
|
1378
|
+
return f"{resolved_root}.{'.'.join(parts[1:])}"
|
|
1379
|
+
return resolved_root
|
|
1380
|
+
return name
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
class TypeScriptSymbolTable:
|
|
1384
|
+
"""Symbol table for TypeScript files with import tracking.
|
|
1385
|
+
|
|
1386
|
+
Tracks imports to:
|
|
1387
|
+
- Skip type-only imports (they don't generate runtime calls)
|
|
1388
|
+
- Identify namespace imports for better SCIP matching
|
|
1389
|
+
|
|
1390
|
+
Resolution is primarily handled by SCIP for accuracy.
|
|
1391
|
+
This table provides supplementary information.
|
|
1392
|
+
"""
|
|
1393
|
+
|
|
1394
|
+
def __init__(self, file_path: str, project_root: str):
|
|
1395
|
+
"""Initialize the symbol table for a TypeScript file.
|
|
1396
|
+
|
|
1397
|
+
Args:
|
|
1398
|
+
file_path: Absolute or relative path to the TypeScript file
|
|
1399
|
+
project_root: Root directory of the project
|
|
1400
|
+
"""
|
|
1401
|
+
self.file_path = file_path
|
|
1402
|
+
self.file_dir = os.path.dirname(file_path)
|
|
1403
|
+
self.project_root = project_root
|
|
1404
|
+
|
|
1405
|
+
# local_name -> (module_path, import_type)
|
|
1406
|
+
# import_type: "named", "default", "namespace", "type"
|
|
1407
|
+
self.imports: dict = {}
|
|
1408
|
+
|
|
1409
|
+
# namespace_alias -> module_path (for import * as alias from 'module')
|
|
1410
|
+
self.namespaces: dict = {}
|
|
1411
|
+
|
|
1412
|
+
def load_imports(self, import_data: dict):
|
|
1413
|
+
"""Load import data from ast-grep extraction.
|
|
1414
|
+
|
|
1415
|
+
Args:
|
|
1416
|
+
import_data: Dict with 'imports' and 'namespaces' keys
|
|
1417
|
+
"""
|
|
1418
|
+
self.imports = import_data.get("imports", {})
|
|
1419
|
+
self.namespaces = import_data.get("namespaces", {})
|
|
1420
|
+
|
|
1421
|
+
def is_type_import(self, name: str) -> bool:
|
|
1422
|
+
"""Check if a name was imported with 'import type'.
|
|
1423
|
+
|
|
1424
|
+
Type-only imports don't generate runtime calls.
|
|
1425
|
+
"""
|
|
1426
|
+
root = name.split(".")[0]
|
|
1427
|
+
if root in self.imports:
|
|
1428
|
+
_, import_type = self.imports[root]
|
|
1429
|
+
return import_type == "type"
|
|
1430
|
+
return False
|
|
1431
|
+
|
|
1432
|
+
def is_namespace_call(self, name: str) -> bool:
|
|
1433
|
+
"""Check if name is a call on a namespace import (e.g., api.get).
|
|
1434
|
+
|
|
1435
|
+
Namespace calls like 'schedulesApi.list' are qualified names that
|
|
1436
|
+
SCIP can resolve when given the full qualified form.
|
|
1437
|
+
"""
|
|
1438
|
+
if "." not in name:
|
|
1439
|
+
return False
|
|
1440
|
+
root = name.split(".")[0]
|
|
1441
|
+
return root in self.namespaces
|
|
1442
|
+
|
|
1443
|
+
def get_import_source(self, name: str) -> str | None:
|
|
1444
|
+
"""Get the import source for a name if it was imported.
|
|
1445
|
+
|
|
1446
|
+
Args:
|
|
1447
|
+
name: Local name to look up
|
|
1448
|
+
|
|
1449
|
+
Returns:
|
|
1450
|
+
Module path if imported, None otherwise
|
|
1451
|
+
"""
|
|
1452
|
+
root = name.split(".")[0]
|
|
1453
|
+
if root in self.imports:
|
|
1454
|
+
module, _ = self.imports[root]
|
|
1455
|
+
return module
|
|
1456
|
+
if root in self.namespaces:
|
|
1457
|
+
return self.namespaces[root]
|
|
1458
|
+
return None
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
class BaseParser:
|
|
1462
|
+
def __init__(self, builder):
|
|
1463
|
+
self.builder = builder
|
|
1464
|
+
|
|
1465
|
+
def parse(self, file_path, rel_path, content):
|
|
1466
|
+
raise NotImplementedError
|
|
1467
|
+
|
|
1468
|
+
def get_leading_docstring(self, lines, start_idx):
|
|
1469
|
+
doc_lines = []
|
|
1470
|
+
j = start_idx - 1
|
|
1471
|
+
while j >= 0:
|
|
1472
|
+
line = lines[j].strip()
|
|
1473
|
+
# Handle common comment styles: ///, //, /*, *, /**
|
|
1474
|
+
if (
|
|
1475
|
+
line.startswith("///")
|
|
1476
|
+
or line.startswith("//!")
|
|
1477
|
+
or line.startswith("*")
|
|
1478
|
+
or line.startswith("/**")
|
|
1479
|
+
or line.startswith("//")
|
|
1480
|
+
):
|
|
1481
|
+
# Skip end of block comments if they don't contain content
|
|
1482
|
+
if line == "*/":
|
|
1483
|
+
j -= 1
|
|
1484
|
+
continue
|
|
1485
|
+
cleaned = line.lstrip("/!* ").strip()
|
|
1486
|
+
doc_lines.insert(0, cleaned)
|
|
1487
|
+
j -= 1
|
|
1488
|
+
else:
|
|
1489
|
+
break
|
|
1490
|
+
return "\n".join(doc_lines)
|
|
1491
|
+
|
|
1492
|
+
def _find_block_end(self, lines, start_idx):
|
|
1493
|
+
"""Find the closing brace line for a block starting at start_idx.
|
|
1494
|
+
|
|
1495
|
+
Counts braces to find the matching closing brace.
|
|
1496
|
+
Returns the 1-indexed line number of the closing brace.
|
|
1497
|
+
"""
|
|
1498
|
+
brace_depth = 0
|
|
1499
|
+
found_open = False
|
|
1500
|
+
|
|
1501
|
+
for i in range(start_idx, len(lines)):
|
|
1502
|
+
line = lines[i]
|
|
1503
|
+
for char in line:
|
|
1504
|
+
if char == "{":
|
|
1505
|
+
brace_depth += 1
|
|
1506
|
+
found_open = True
|
|
1507
|
+
elif char == "}":
|
|
1508
|
+
brace_depth -= 1
|
|
1509
|
+
if found_open and brace_depth == 0:
|
|
1510
|
+
return i + 1 # 1-indexed line number
|
|
1511
|
+
|
|
1512
|
+
# If no closing brace found, estimate based on file end
|
|
1513
|
+
return min(start_idx + 100, len(lines))
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
class PythonParser(BaseParser):
|
|
1517
|
+
def parse(self, file_path, rel_path, content):
|
|
1518
|
+
file_id = f"FILE:{rel_path}"
|
|
1519
|
+
self.builder.current_file_id = file_id
|
|
1520
|
+
self.builder.current_scope = SymbolTable()
|
|
1521
|
+
|
|
1522
|
+
try:
|
|
1523
|
+
tree = ast.parse(content, filename=str(file_path))
|
|
1524
|
+
except SyntaxError as e:
|
|
1525
|
+
logger.warning(f"Syntax error in {rel_path}:{e.lineno}: {e.msg}")
|
|
1526
|
+
return
|
|
1527
|
+
except Exception as e:
|
|
1528
|
+
logger.warning(f"Parse error in {rel_path}: {e}")
|
|
1529
|
+
return
|
|
1530
|
+
|
|
1531
|
+
self.builder.add_node(
|
|
1532
|
+
file_id,
|
|
1533
|
+
"File",
|
|
1534
|
+
path=rel_path,
|
|
1535
|
+
name=Path(rel_path).name,
|
|
1536
|
+
token_count=len(content) // 4,
|
|
1537
|
+
)
|
|
1538
|
+
self.visit_node(tree, file_id)
|
|
1539
|
+
|
|
1540
|
+
def _get_type_annotation(self, annotation):
|
|
1541
|
+
"""Extract type annotation as a string.
|
|
1542
|
+
|
|
1543
|
+
Handles:
|
|
1544
|
+
- Simple types: int, str, MyClass
|
|
1545
|
+
- Qualified types: module.Type
|
|
1546
|
+
- Generic types: List[int], Dict[str, int]
|
|
1547
|
+
- Union types (3.10+): int | str
|
|
1548
|
+
- Tuple types: tuple[int, str]
|
|
1549
|
+
- Optional: Optional[int]
|
|
1550
|
+
- Literal: Literal["foo"]
|
|
1551
|
+
"""
|
|
1552
|
+
if annotation is None:
|
|
1553
|
+
return "Any"
|
|
1554
|
+
if isinstance(annotation, ast.Name):
|
|
1555
|
+
return annotation.id
|
|
1556
|
+
elif isinstance(annotation, ast.Attribute):
|
|
1557
|
+
base = self._get_attribute_name(annotation.value)
|
|
1558
|
+
return f"{base}.{annotation.attr}" if base else annotation.attr
|
|
1559
|
+
elif isinstance(annotation, ast.Subscript):
|
|
1560
|
+
base = self._get_type_annotation(annotation.value)
|
|
1561
|
+
slice_val = annotation.slice
|
|
1562
|
+
# Handle tuple slices (Dict[K, V], Callable[[...], R])
|
|
1563
|
+
if isinstance(slice_val, ast.Tuple):
|
|
1564
|
+
elts = ", ".join(self._get_type_annotation(e) for e in slice_val.elts)
|
|
1565
|
+
return f"{base}[{elts}]"
|
|
1566
|
+
else:
|
|
1567
|
+
return f"{base}[{self._get_type_annotation(slice_val)}]"
|
|
1568
|
+
elif isinstance(annotation, ast.Constant):
|
|
1569
|
+
# Literal types like Literal["foo"]
|
|
1570
|
+
return (
|
|
1571
|
+
repr(annotation.value)
|
|
1572
|
+
if isinstance(annotation.value, str)
|
|
1573
|
+
else str(annotation.value)
|
|
1574
|
+
)
|
|
1575
|
+
elif isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr):
|
|
1576
|
+
# Union types (3.10+): int | str
|
|
1577
|
+
left = self._get_type_annotation(annotation.left)
|
|
1578
|
+
right = self._get_type_annotation(annotation.right)
|
|
1579
|
+
return f"{left} | {right}"
|
|
1580
|
+
elif isinstance(annotation, ast.Tuple):
|
|
1581
|
+
# Bare tuple in annotations
|
|
1582
|
+
elts = ", ".join(self._get_type_annotation(e) for e in annotation.elts)
|
|
1583
|
+
return f"({elts})"
|
|
1584
|
+
elif isinstance(annotation, ast.List):
|
|
1585
|
+
# List in Callable[[arg1, arg2], return]
|
|
1586
|
+
elts = ", ".join(self._get_type_annotation(e) for e in annotation.elts)
|
|
1587
|
+
return f"[{elts}]"
|
|
1588
|
+
return "Complex"
|
|
1589
|
+
|
|
1590
|
+
def _get_args_info(self, args_node):
|
|
1591
|
+
args_list = []
|
|
1592
|
+
for arg in args_node.args:
|
|
1593
|
+
arg_str = arg.arg
|
|
1594
|
+
if arg.annotation:
|
|
1595
|
+
arg_str += f": {self._get_type_annotation(arg.annotation)}"
|
|
1596
|
+
args_list.append(arg_str)
|
|
1597
|
+
return args_list
|
|
1598
|
+
|
|
1599
|
+
def _get_param_types(self, args_node):
|
|
1600
|
+
"""Extract parameter types as a list for metadata storage."""
|
|
1601
|
+
param_types = []
|
|
1602
|
+
for arg in args_node.args:
|
|
1603
|
+
if arg.annotation:
|
|
1604
|
+
param_types.append(self._get_type_annotation(arg.annotation))
|
|
1605
|
+
else:
|
|
1606
|
+
param_types.append("Any")
|
|
1607
|
+
return param_types
|
|
1608
|
+
|
|
1609
|
+
def _estimate_tokens(self, node):
|
|
1610
|
+
if hasattr(node, "end_lineno") and hasattr(node, "lineno"):
|
|
1611
|
+
return (node.end_lineno - node.lineno + 1) * 10
|
|
1612
|
+
return 0
|
|
1613
|
+
|
|
1614
|
+
def visit_node(self, node, parent_id):
|
|
1615
|
+
if isinstance(node, ast.ClassDef):
|
|
1616
|
+
class_id = f"{parent_id}::{node.name}"
|
|
1617
|
+
self.builder.add_node(
|
|
1618
|
+
class_id,
|
|
1619
|
+
"Class",
|
|
1620
|
+
name=node.name,
|
|
1621
|
+
lineno=node.lineno,
|
|
1622
|
+
end_lineno=getattr(node, "end_lineno", node.lineno),
|
|
1623
|
+
token_count=self._estimate_tokens(node),
|
|
1624
|
+
docstring=ast.get_docstring(node) or "",
|
|
1625
|
+
)
|
|
1626
|
+
self.builder.add_edge(parent_id, class_id, "DEFINES")
|
|
1627
|
+
|
|
1628
|
+
# Track decorator calls (e.g., @dataclass, @pytest.mark.parametrize(...))
|
|
1629
|
+
for decorator in node.decorator_list:
|
|
1630
|
+
if isinstance(decorator, ast.Call):
|
|
1631
|
+
self._record_call(decorator, class_id, decorator.lineno)
|
|
1632
|
+
elif isinstance(decorator, ast.Attribute):
|
|
1633
|
+
# Handle @module.decorator without call parens
|
|
1634
|
+
name = self._get_attribute_name(decorator)
|
|
1635
|
+
if name and not is_non_project_call(name):
|
|
1636
|
+
self.builder.edges.append(
|
|
1637
|
+
{
|
|
1638
|
+
"source": class_id,
|
|
1639
|
+
"target": f"REF:{name}",
|
|
1640
|
+
"relation": "CALLS",
|
|
1641
|
+
"metadata": {"lineno": decorator.lineno},
|
|
1642
|
+
}
|
|
1643
|
+
)
|
|
1644
|
+
|
|
1645
|
+
for base in node.bases:
|
|
1646
|
+
base_name = (
|
|
1647
|
+
self._get_attribute_name(base)
|
|
1648
|
+
if isinstance(base, ast.Attribute)
|
|
1649
|
+
else (base.id if isinstance(base, ast.Name) else None)
|
|
1650
|
+
)
|
|
1651
|
+
if base_name:
|
|
1652
|
+
resolved_base = self.builder.current_scope.resolve(base_name)
|
|
1653
|
+
self.builder.add_edge(class_id, f"REF:{resolved_base}", "INHERITS")
|
|
1654
|
+
|
|
1655
|
+
for child in node.body:
|
|
1656
|
+
self.visit_node(child, class_id)
|
|
1657
|
+
|
|
1658
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1659
|
+
func_id = f"{parent_id}::{node.name}"
|
|
1660
|
+
# D.1: a function is a method iff its enclosing parent node is a Class.
|
|
1661
|
+
# Previous logic used string matching on parent_id which incorrectly
|
|
1662
|
+
# classified nested functions inside other functions as Methods.
|
|
1663
|
+
parent_node = next(
|
|
1664
|
+
(n for n in self.builder.nodes if n["id"] == parent_id), None
|
|
1665
|
+
)
|
|
1666
|
+
is_method = parent_node is not None and parent_node["type"] == "Class"
|
|
1667
|
+
node_type = "Method" if is_method else "Function"
|
|
1668
|
+
|
|
1669
|
+
args = self._get_args_info(node.args)
|
|
1670
|
+
param_types = self._get_param_types(node.args)
|
|
1671
|
+
return_type = (
|
|
1672
|
+
self._get_type_annotation(node.returns) if node.returns else "None"
|
|
1673
|
+
)
|
|
1674
|
+
signature = f"def {node.name}({', '.join(args)}) -> {return_type}"
|
|
1675
|
+
|
|
1676
|
+
self.builder.add_node(
|
|
1677
|
+
func_id,
|
|
1678
|
+
node_type,
|
|
1679
|
+
name=node.name,
|
|
1680
|
+
signature=signature,
|
|
1681
|
+
lineno=node.lineno,
|
|
1682
|
+
end_lineno=getattr(node, "end_lineno", node.lineno),
|
|
1683
|
+
token_count=self._estimate_tokens(node),
|
|
1684
|
+
docstring=ast.get_docstring(node) or "",
|
|
1685
|
+
return_type=return_type,
|
|
1686
|
+
param_types=param_types,
|
|
1687
|
+
)
|
|
1688
|
+
self.builder.add_edge(parent_id, func_id, "DEFINES")
|
|
1689
|
+
|
|
1690
|
+
# Track decorator calls (e.g., @lru_cache(), @pytest.fixture)
|
|
1691
|
+
for decorator in node.decorator_list:
|
|
1692
|
+
if isinstance(decorator, ast.Call):
|
|
1693
|
+
self._record_call(decorator, func_id, decorator.lineno)
|
|
1694
|
+
elif isinstance(decorator, ast.Attribute):
|
|
1695
|
+
name = self._get_attribute_name(decorator)
|
|
1696
|
+
if name and not is_non_project_call(name):
|
|
1697
|
+
self.builder.edges.append(
|
|
1698
|
+
{
|
|
1699
|
+
"source": func_id,
|
|
1700
|
+
"target": f"REF:{name}",
|
|
1701
|
+
"relation": "CALLS",
|
|
1702
|
+
"metadata": {"lineno": decorator.lineno},
|
|
1703
|
+
}
|
|
1704
|
+
)
|
|
1705
|
+
elif isinstance(decorator, ast.Name):
|
|
1706
|
+
if not is_non_project_call(decorator.id):
|
|
1707
|
+
self.builder.edges.append(
|
|
1708
|
+
{
|
|
1709
|
+
"source": func_id,
|
|
1710
|
+
"target": f"REF:{decorator.id}",
|
|
1711
|
+
"relation": "CALLS",
|
|
1712
|
+
"metadata": {"lineno": decorator.lineno},
|
|
1713
|
+
}
|
|
1714
|
+
)
|
|
1715
|
+
|
|
1716
|
+
self.visit_body_for_calls(node.body, func_id)
|
|
1717
|
+
|
|
1718
|
+
for child in node.body:
|
|
1719
|
+
if isinstance(
|
|
1720
|
+
child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
1721
|
+
):
|
|
1722
|
+
self.visit_node(child, func_id)
|
|
1723
|
+
|
|
1724
|
+
elif isinstance(node, ast.Import):
|
|
1725
|
+
for alias in node.names:
|
|
1726
|
+
self.builder.current_scope.add_import(alias.name, alias=alias.asname)
|
|
1727
|
+
self.builder.add_edge(
|
|
1728
|
+
self.builder.current_file_id, f"MODULE:{alias.name}", "IMPORTS"
|
|
1729
|
+
)
|
|
1730
|
+
|
|
1731
|
+
elif isinstance(node, ast.ImportFrom):
|
|
1732
|
+
module = node.module if node.module else ""
|
|
1733
|
+
for alias in node.names:
|
|
1734
|
+
full_name = f"{module}.{alias.name}" if module else alias.name
|
|
1735
|
+
self.builder.current_scope.add_import(
|
|
1736
|
+
alias.name, module=module, alias=alias.asname
|
|
1737
|
+
)
|
|
1738
|
+
self.builder.add_edge(
|
|
1739
|
+
self.builder.current_file_id, f"MODULE:{full_name}", "IMPORTS"
|
|
1740
|
+
)
|
|
1741
|
+
|
|
1742
|
+
elif isinstance(node, ast.Module):
|
|
1743
|
+
for child in node.body:
|
|
1744
|
+
self.visit_node(child, parent_id)
|
|
1745
|
+
|
|
1746
|
+
def visit_body_for_calls(self, body, parent_func_id):
|
|
1747
|
+
"""Find all Call nodes in the function body using ast.walk().
|
|
1748
|
+
|
|
1749
|
+
This catches calls in all contexts: comprehensions, generators,
|
|
1750
|
+
with statements, if/while conditions, for iterables, decorators,
|
|
1751
|
+
nested calls, await expressions, etc.
|
|
1752
|
+
"""
|
|
1753
|
+
for node in body:
|
|
1754
|
+
for child in ast.walk(node):
|
|
1755
|
+
if isinstance(child, ast.Call):
|
|
1756
|
+
lineno = getattr(child, "lineno", 0)
|
|
1757
|
+
self._record_call(child, parent_func_id, lineno)
|
|
1758
|
+
|
|
1759
|
+
def _record_call(self, call_node, parent_func_id, lineno):
|
|
1760
|
+
func_name = self._get_func_name(call_node.func)
|
|
1761
|
+
if func_name:
|
|
1762
|
+
resolved_name = self.builder.current_scope.resolve(func_name)
|
|
1763
|
+
if not is_non_project_call(resolved_name):
|
|
1764
|
+
self.builder.edges.append(
|
|
1765
|
+
{
|
|
1766
|
+
"source": parent_func_id,
|
|
1767
|
+
"target": f"REF:{resolved_name}",
|
|
1768
|
+
"relation": "CALLS",
|
|
1769
|
+
"metadata": {"lineno": lineno},
|
|
1770
|
+
}
|
|
1771
|
+
)
|
|
1772
|
+
|
|
1773
|
+
def _get_func_name(self, func_node):
|
|
1774
|
+
if isinstance(func_node, ast.Name):
|
|
1775
|
+
return func_node.id
|
|
1776
|
+
elif isinstance(func_node, ast.Attribute):
|
|
1777
|
+
return self._get_attribute_name(func_node)
|
|
1778
|
+
return None
|
|
1779
|
+
|
|
1780
|
+
def _get_attribute_name(self, node):
|
|
1781
|
+
if isinstance(node, ast.Name):
|
|
1782
|
+
return node.id
|
|
1783
|
+
elif isinstance(node, ast.Attribute):
|
|
1784
|
+
value = self._get_attribute_name(node.value)
|
|
1785
|
+
return f"{value}.{node.attr}" if value else node.attr
|
|
1786
|
+
return None
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
class RustParser(BaseParser):
|
|
1790
|
+
def parse(self, file_path, rel_path, content):
|
|
1791
|
+
file_id = f"FILE:{rel_path}"
|
|
1792
|
+
self.builder.add_node(
|
|
1793
|
+
file_id,
|
|
1794
|
+
"File",
|
|
1795
|
+
path=rel_path,
|
|
1796
|
+
name=Path(rel_path).name,
|
|
1797
|
+
token_count=len(content) // 4,
|
|
1798
|
+
)
|
|
1799
|
+
|
|
1800
|
+
lines = content.splitlines()
|
|
1801
|
+
current_context = [file_id] # Stack of IDs
|
|
1802
|
+
context_indent = [0] # Stack of indentation levels
|
|
1803
|
+
|
|
1804
|
+
# Regex patterns
|
|
1805
|
+
re_fn_start = re.compile(
|
|
1806
|
+
r"^\s*(?:pub\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+([a-zA-Z0-9_]+)"
|
|
1807
|
+
)
|
|
1808
|
+
re_struct = re.compile(r"^\s*(?:pub\s+)?(?:struct|trait)\s+([a-zA-Z0-9_]+)")
|
|
1809
|
+
re_enum = re.compile(r"^\s*(?:pub\s+)?enum\s+([a-zA-Z0-9_]+)")
|
|
1810
|
+
# Enum variant patterns: Name, Name(Type), Name { field: Type }
|
|
1811
|
+
re_enum_variant = re.compile(r"^\s*([A-Z][a-zA-Z0-9_]*)\s*(?:[({\,]|$)")
|
|
1812
|
+
re_impl = re.compile(
|
|
1813
|
+
r"^\s*impl(?:<.*?>)?\s+(?:([a-zA-Z0-9_:<>,\s]+?)\s+for\s+)?([a-zA-Z0-9_:]+)"
|
|
1814
|
+
)
|
|
1815
|
+
re_mod = re.compile(r"^\s*(?:pub\s+)?mod\s+([a-zA-Z0-9_]+)\s*\{?")
|
|
1816
|
+
re_use = re.compile(r"^\s*(?:pub\s+)?use\s+([^;]+);")
|
|
1817
|
+
re_const = re.compile(
|
|
1818
|
+
r"^\s*(?:pub(?:\(.*?\))?\s+)?(?:const|static)\s+([A-Z0-9_]+)\s*:\s*.*?\s*=\s*(.*?);"
|
|
1819
|
+
)
|
|
1820
|
+
re_call = re.compile(r"([a-zA-Z0-9_:]+)\(")
|
|
1821
|
+
|
|
1822
|
+
current_impl_target = None
|
|
1823
|
+
current_trait_impl = (
|
|
1824
|
+
None # Track trait name when in "impl Trait for Struct" block
|
|
1825
|
+
)
|
|
1826
|
+
current_enum_id = None # Track if we're inside an enum for variant parsing
|
|
1827
|
+
|
|
1828
|
+
i = 0
|
|
1829
|
+
while i < len(lines):
|
|
1830
|
+
line = lines[i]
|
|
1831
|
+
lineno = i + 1
|
|
1832
|
+
indent = len(line) - len(line.lstrip())
|
|
1833
|
+
|
|
1834
|
+
# Pop context
|
|
1835
|
+
if "}" in line:
|
|
1836
|
+
while len(context_indent) > 1 and indent <= context_indent[-1]:
|
|
1837
|
+
context_indent.pop()
|
|
1838
|
+
popped_id = current_context.pop()
|
|
1839
|
+
# Reset enum tracking when leaving enum scope
|
|
1840
|
+
if current_enum_id and popped_id == current_enum_id:
|
|
1841
|
+
current_enum_id = None
|
|
1842
|
+
if len(current_context) == 1:
|
|
1843
|
+
current_impl_target = None
|
|
1844
|
+
current_trait_impl = None
|
|
1845
|
+
|
|
1846
|
+
parent_id = current_context[-1]
|
|
1847
|
+
|
|
1848
|
+
# 0. Constants
|
|
1849
|
+
if match := re_const.search(line):
|
|
1850
|
+
name, val = match.groups()
|
|
1851
|
+
docstring = self.get_leading_docstring(lines, i)
|
|
1852
|
+
cid = f"{parent_id}::{name}"
|
|
1853
|
+
self.builder.add_node(
|
|
1854
|
+
cid,
|
|
1855
|
+
"Constant",
|
|
1856
|
+
name=name,
|
|
1857
|
+
value=val,
|
|
1858
|
+
lineno=lineno,
|
|
1859
|
+
docstring=docstring,
|
|
1860
|
+
)
|
|
1861
|
+
self.builder.add_edge(parent_id, cid, "DEFINES")
|
|
1862
|
+
|
|
1863
|
+
# 1. Functions
|
|
1864
|
+
if match := re_fn_start.search(line):
|
|
1865
|
+
name = match.group(1)
|
|
1866
|
+
j, full_sig = self._consume_until_body(lines, i)
|
|
1867
|
+
|
|
1868
|
+
ret = "()"
|
|
1869
|
+
param_types = []
|
|
1870
|
+
if "->" in full_sig:
|
|
1871
|
+
try:
|
|
1872
|
+
parts = full_sig.split("->")
|
|
1873
|
+
ret_part = parts[-1].split("{")[0].strip().strip(";")
|
|
1874
|
+
ret = ret_part
|
|
1875
|
+
except (IndexError, ValueError):
|
|
1876
|
+
pass
|
|
1877
|
+
|
|
1878
|
+
# Extract parameter types using lightweight heuristics
|
|
1879
|
+
param_types = self._extract_rust_param_types(full_sig)
|
|
1880
|
+
|
|
1881
|
+
sig = f"fn {name}(...) -> {ret}"
|
|
1882
|
+
docstring = self.get_leading_docstring(lines, i)
|
|
1883
|
+
|
|
1884
|
+
if current_impl_target:
|
|
1885
|
+
func_id = f"{file_id}::{current_impl_target}::{name}"
|
|
1886
|
+
node_type = "Method"
|
|
1887
|
+
else:
|
|
1888
|
+
func_id = f"{parent_id}::{name}"
|
|
1889
|
+
node_type = "Function"
|
|
1890
|
+
|
|
1891
|
+
# Find actual function end line by counting braces
|
|
1892
|
+
end_lineno = (
|
|
1893
|
+
self._find_block_end(lines, j) if "{" in lines[j] else lineno
|
|
1894
|
+
)
|
|
1895
|
+
|
|
1896
|
+
# Estimate tokens: ~10 tokens per line of code
|
|
1897
|
+
token_count = (end_lineno - lineno + 1) * 10
|
|
1898
|
+
|
|
1899
|
+
# Build metadata dict, only include trait_impl if set
|
|
1900
|
+
node_kwargs = dict(
|
|
1901
|
+
name=name,
|
|
1902
|
+
signature=sig,
|
|
1903
|
+
lineno=lineno,
|
|
1904
|
+
end_lineno=end_lineno,
|
|
1905
|
+
token_count=token_count,
|
|
1906
|
+
docstring=docstring,
|
|
1907
|
+
return_type=ret,
|
|
1908
|
+
param_types=param_types,
|
|
1909
|
+
)
|
|
1910
|
+
if current_trait_impl and node_type == "Method":
|
|
1911
|
+
node_kwargs["trait_impl"] = current_trait_impl
|
|
1912
|
+
|
|
1913
|
+
self.builder.add_node(func_id, node_type, **node_kwargs)
|
|
1914
|
+
self.builder.add_edge(parent_id, func_id, "DEFINES")
|
|
1915
|
+
|
|
1916
|
+
if "{" in lines[j]:
|
|
1917
|
+
current_context.append(func_id)
|
|
1918
|
+
context_indent.append(indent)
|
|
1919
|
+
i = j
|
|
1920
|
+
|
|
1921
|
+
# 2. Structs/Traits
|
|
1922
|
+
elif match := re_struct.search(line):
|
|
1923
|
+
name = match.group(1)
|
|
1924
|
+
docstring = self.get_leading_docstring(lines, i)
|
|
1925
|
+
type_id = f"{parent_id}::{name}"
|
|
1926
|
+
# Find end of struct/trait block
|
|
1927
|
+
end_lineno = self._find_block_end(lines, i) if "{" in line else lineno
|
|
1928
|
+
token_count = (end_lineno - lineno + 1) * 10
|
|
1929
|
+
self.builder.add_node(
|
|
1930
|
+
type_id,
|
|
1931
|
+
"Class",
|
|
1932
|
+
name=name,
|
|
1933
|
+
lineno=lineno,
|
|
1934
|
+
end_lineno=end_lineno,
|
|
1935
|
+
token_count=token_count,
|
|
1936
|
+
docstring=docstring,
|
|
1937
|
+
)
|
|
1938
|
+
self.builder.add_edge(parent_id, type_id, "DEFINES")
|
|
1939
|
+
if "{" in line:
|
|
1940
|
+
current_context.append(type_id)
|
|
1941
|
+
context_indent.append(indent)
|
|
1942
|
+
|
|
1943
|
+
# 2b. Enums (separate to track for variant parsing)
|
|
1944
|
+
elif match := re_enum.search(line):
|
|
1945
|
+
name = match.group(1)
|
|
1946
|
+
docstring = self.get_leading_docstring(lines, i)
|
|
1947
|
+
type_id = f"{parent_id}::{name}"
|
|
1948
|
+
# Find end of enum block
|
|
1949
|
+
end_lineno = self._find_block_end(lines, i) if "{" in line else lineno
|
|
1950
|
+
token_count = (end_lineno - lineno + 1) * 10
|
|
1951
|
+
self.builder.add_node(
|
|
1952
|
+
type_id,
|
|
1953
|
+
"Class",
|
|
1954
|
+
name=name,
|
|
1955
|
+
lineno=lineno,
|
|
1956
|
+
end_lineno=end_lineno,
|
|
1957
|
+
token_count=token_count,
|
|
1958
|
+
docstring=docstring,
|
|
1959
|
+
)
|
|
1960
|
+
self.builder.add_edge(parent_id, type_id, "DEFINES")
|
|
1961
|
+
if "{" in line:
|
|
1962
|
+
current_context.append(type_id)
|
|
1963
|
+
context_indent.append(indent)
|
|
1964
|
+
current_enum_id = type_id # Track enum for variant parsing
|
|
1965
|
+
|
|
1966
|
+
# 2c. Enum variants (when inside an enum)
|
|
1967
|
+
elif current_enum_id and (match := re_enum_variant.search(line)):
|
|
1968
|
+
variant_name = match.group(1)
|
|
1969
|
+
# Skip common Rust keywords/patterns that match variant regex
|
|
1970
|
+
if variant_name not in (
|
|
1971
|
+
"Self",
|
|
1972
|
+
"Some",
|
|
1973
|
+
"None",
|
|
1974
|
+
"Ok",
|
|
1975
|
+
"Err",
|
|
1976
|
+
"Box",
|
|
1977
|
+
"Vec",
|
|
1978
|
+
"Option",
|
|
1979
|
+
"Result",
|
|
1980
|
+
):
|
|
1981
|
+
variant_id = f"{current_enum_id}::{variant_name}"
|
|
1982
|
+
self.builder.add_node(
|
|
1983
|
+
variant_id,
|
|
1984
|
+
"Function", # Treat as callable for resolution
|
|
1985
|
+
name=variant_name,
|
|
1986
|
+
signature=f"{current_enum_id.split('::')[-1]}::{variant_name}",
|
|
1987
|
+
lineno=lineno,
|
|
1988
|
+
docstring="",
|
|
1989
|
+
)
|
|
1990
|
+
self.builder.add_edge(current_enum_id, variant_id, "DEFINES")
|
|
1991
|
+
|
|
1992
|
+
# 3. Impl
|
|
1993
|
+
elif match := re_impl.search(line):
|
|
1994
|
+
trait, target = match.groups()
|
|
1995
|
+
real_target = (target if target else trait).split("<")[0]
|
|
1996
|
+
current_impl_target = real_target.split("::")[-1]
|
|
1997
|
+
|
|
1998
|
+
if trait:
|
|
1999
|
+
trait_name = trait.split("<")[0].split("::")[-1]
|
|
2000
|
+
current_trait_impl = trait_name # Track trait for method metadata
|
|
2001
|
+
struct_id = f"{file_id}::{current_impl_target}"
|
|
2002
|
+
self.builder.add_edge(struct_id, f"REF:{trait_name}", "INHERITS")
|
|
2003
|
+
else:
|
|
2004
|
+
current_trait_impl = None # Inherent impl, no trait
|
|
2005
|
+
|
|
2006
|
+
if "{" in line:
|
|
2007
|
+
struct_id = f"{file_id}::{current_impl_target}"
|
|
2008
|
+
current_context.append(struct_id)
|
|
2009
|
+
context_indent.append(indent)
|
|
2010
|
+
|
|
2011
|
+
# 4. Modules
|
|
2012
|
+
elif match := re_mod.search(line):
|
|
2013
|
+
name = match.group(1)
|
|
2014
|
+
mod_id = f"{parent_id}::{name}"
|
|
2015
|
+
if "{" in line:
|
|
2016
|
+
current_context.append(mod_id)
|
|
2017
|
+
context_indent.append(indent)
|
|
2018
|
+
|
|
2019
|
+
# 5. Imports
|
|
2020
|
+
elif match := re_use.search(line):
|
|
2021
|
+
path = match.group(1).replace("::", ".")
|
|
2022
|
+
self.builder.add_edge(file_id, f"MODULE:{path}", "IMPORTS")
|
|
2023
|
+
|
|
2024
|
+
# 6. Calls (regex fallback - only used if ast-grep unavailable)
|
|
2025
|
+
if not USE_AST_GREP and parent_id != file_id:
|
|
2026
|
+
for call_match in re_call.finditer(line):
|
|
2027
|
+
callee = call_match.group(1)
|
|
2028
|
+
if not is_non_project_call(callee):
|
|
2029
|
+
self.builder.edges.append(
|
|
2030
|
+
{
|
|
2031
|
+
"source": parent_id,
|
|
2032
|
+
"target": f"REF:{callee}",
|
|
2033
|
+
"relation": "CALLS",
|
|
2034
|
+
"metadata": {"lineno": lineno},
|
|
2035
|
+
}
|
|
2036
|
+
)
|
|
2037
|
+
i += 1
|
|
2038
|
+
|
|
2039
|
+
# Use ast-grep for more accurate CALLS detection
|
|
2040
|
+
if USE_AST_GREP:
|
|
2041
|
+
self._extract_calls_with_ast_grep(str(file_path), file_id, lines)
|
|
2042
|
+
|
|
2043
|
+
def _extract_calls_with_ast_grep(self, file_path: str, file_id: str, lines: list):
|
|
2044
|
+
"""Extract calls using ast-grep for higher accuracy."""
|
|
2045
|
+
# Build a line-to-context map from our parsed structure
|
|
2046
|
+
# Uses span-size ordering to ensure innermost context wins for nested functions
|
|
2047
|
+
line_to_context = build_line_to_context_map(self.builder.nodes, file_id)
|
|
2048
|
+
|
|
2049
|
+
for call in extract_calls_rust(file_path):
|
|
2050
|
+
lineno = call["lineno"]
|
|
2051
|
+
callee = call["callee"]
|
|
2052
|
+
|
|
2053
|
+
# Filter stdlib calls
|
|
2054
|
+
if is_non_project_call(callee):
|
|
2055
|
+
continue
|
|
2056
|
+
|
|
2057
|
+
# Find the containing function for this line
|
|
2058
|
+
context_id = line_to_context.get(lineno)
|
|
2059
|
+
if not context_id:
|
|
2060
|
+
# Try nearby lines
|
|
2061
|
+
for offset in range(1, 10):
|
|
2062
|
+
context_id = line_to_context.get(
|
|
2063
|
+
lineno - offset
|
|
2064
|
+
) or line_to_context.get(lineno + offset)
|
|
2065
|
+
if context_id:
|
|
2066
|
+
break
|
|
2067
|
+
|
|
2068
|
+
# Fall back to file_id if no function context found (file-level calls)
|
|
2069
|
+
source_id = context_id if context_id else file_id
|
|
2070
|
+
self.builder.edges.append(
|
|
2071
|
+
{
|
|
2072
|
+
"source": source_id,
|
|
2073
|
+
"target": f"REF:{callee}",
|
|
2074
|
+
"relation": "CALLS",
|
|
2075
|
+
"metadata": {"lineno": lineno},
|
|
2076
|
+
}
|
|
2077
|
+
)
|
|
2078
|
+
|
|
2079
|
+
# Track struct instantiation patterns
|
|
2080
|
+
# Expanded pattern list covers common Rust constructors
|
|
2081
|
+
constructor_patterns = (
|
|
2082
|
+
"new",
|
|
2083
|
+
"default",
|
|
2084
|
+
"builder",
|
|
2085
|
+
"from",
|
|
2086
|
+
"try_from",
|
|
2087
|
+
"from_str",
|
|
2088
|
+
"from_bytes",
|
|
2089
|
+
"from_slice",
|
|
2090
|
+
"open",
|
|
2091
|
+
"create",
|
|
2092
|
+
"connect",
|
|
2093
|
+
"with_capacity",
|
|
2094
|
+
"with_config",
|
|
2095
|
+
"with_options",
|
|
2096
|
+
"init",
|
|
2097
|
+
"initialize",
|
|
2098
|
+
)
|
|
2099
|
+
if "::" in callee:
|
|
2100
|
+
parts = callee.split("::")
|
|
2101
|
+
if len(parts) >= 2:
|
|
2102
|
+
method_name = parts[-1]
|
|
2103
|
+
struct_name = parts[-2]
|
|
2104
|
+
# Match constructor patterns (exact or prefix for with_*)
|
|
2105
|
+
is_constructor = (
|
|
2106
|
+
method_name in constructor_patterns
|
|
2107
|
+
or method_name.startswith("with_")
|
|
2108
|
+
or method_name.startswith("from_")
|
|
2109
|
+
)
|
|
2110
|
+
# Skip if it's a module path like std::collections::HashMap::new
|
|
2111
|
+
if is_constructor and struct_name[0].isupper():
|
|
2112
|
+
self.builder.edges.append(
|
|
2113
|
+
{
|
|
2114
|
+
"source": source_id,
|
|
2115
|
+
"target": f"REF:{struct_name}",
|
|
2116
|
+
"relation": "INSTANTIATES",
|
|
2117
|
+
"metadata": {"lineno": lineno, "via": callee},
|
|
2118
|
+
}
|
|
2119
|
+
)
|
|
2120
|
+
|
|
2121
|
+
# Also extract struct literal instantiations
|
|
2122
|
+
self._extract_struct_literals(file_path, file_id, lines, line_to_context)
|
|
2123
|
+
|
|
2124
|
+
# Extract calls from macro bodies (tokio::select!, etc.)
|
|
2125
|
+
self._extract_macro_body_calls(file_id, lines, line_to_context)
|
|
2126
|
+
|
|
2127
|
+
def _extract_struct_literals(
|
|
2128
|
+
self, file_path: str, file_id: str, lines: list, line_to_context: dict
|
|
2129
|
+
):
|
|
2130
|
+
"""Extract struct literal instantiations like `MyStruct { field: value }`.
|
|
2131
|
+
|
|
2132
|
+
Uses regex to find patterns that look like struct literals.
|
|
2133
|
+
"""
|
|
2134
|
+
re_struct_literal = re.compile(r"\b([A-Z][a-zA-Z0-9_]*)\s*\{")
|
|
2135
|
+
|
|
2136
|
+
for lineno, line in enumerate(lines, 1):
|
|
2137
|
+
# Skip struct/enum/trait definitions
|
|
2138
|
+
if re.match(r"^\s*(?:pub\s+)?(?:struct|enum|trait)\s+", line):
|
|
2139
|
+
continue
|
|
2140
|
+
# Skip impl blocks
|
|
2141
|
+
if re.match(r"^\s*impl", line):
|
|
2142
|
+
continue
|
|
2143
|
+
|
|
2144
|
+
for match in re_struct_literal.finditer(line):
|
|
2145
|
+
struct_name = match.group(1)
|
|
2146
|
+
# Skip common non-struct patterns
|
|
2147
|
+
if struct_name in ("Some", "None", "Ok", "Err", "Self"):
|
|
2148
|
+
continue
|
|
2149
|
+
|
|
2150
|
+
context_id = line_to_context.get(lineno)
|
|
2151
|
+
if not context_id:
|
|
2152
|
+
for offset in range(1, 10):
|
|
2153
|
+
context_id = line_to_context.get(
|
|
2154
|
+
lineno - offset
|
|
2155
|
+
) or line_to_context.get(lineno + offset)
|
|
2156
|
+
if context_id:
|
|
2157
|
+
break
|
|
2158
|
+
|
|
2159
|
+
source_id = context_id if context_id else file_id
|
|
2160
|
+
self.builder.edges.append(
|
|
2161
|
+
{
|
|
2162
|
+
"source": source_id,
|
|
2163
|
+
"target": f"REF:{struct_name}",
|
|
2164
|
+
"relation": "INSTANTIATES",
|
|
2165
|
+
"metadata": {"lineno": lineno, "via": "literal"},
|
|
2166
|
+
}
|
|
2167
|
+
)
|
|
2168
|
+
|
|
2169
|
+
def _extract_macro_body_calls(
|
|
2170
|
+
self, file_id: str, lines: list, line_to_context: dict
|
|
2171
|
+
):
|
|
2172
|
+
"""Extract function/method calls from inside macro bodies.
|
|
2173
|
+
|
|
2174
|
+
Macro bodies like tokio::select! aren't parsed by ast-grep because
|
|
2175
|
+
they're not valid syntax until macro expansion. This uses regex
|
|
2176
|
+
to find common call patterns inside macro invocations.
|
|
2177
|
+
|
|
2178
|
+
Patterns detected:
|
|
2179
|
+
- self.method() and self.method().await
|
|
2180
|
+
- receiver.method() where receiver is a variable
|
|
2181
|
+
- function() calls
|
|
2182
|
+
"""
|
|
2183
|
+
# Patterns for calls inside macro bodies
|
|
2184
|
+
# Method call: word.method() or word.method().await
|
|
2185
|
+
re_method_call = re.compile(
|
|
2186
|
+
r"\b([a-z_][a-z0-9_]*)\s*\.\s*([a-z_][a-z0-9_]*)\s*\(\s*\)"
|
|
2187
|
+
)
|
|
2188
|
+
# Method call with args: word.method(args)
|
|
2189
|
+
re_method_call_args = re.compile(
|
|
2190
|
+
r"\b([a-z_][a-z0-9_]*)\s*\.\s*([a-z_][a-z0-9_]*)\s*\([^)]*\)"
|
|
2191
|
+
)
|
|
2192
|
+
# Chained await: .method().await
|
|
2193
|
+
re_await_method = re.compile(r"\.([a-z_][a-z0-9_]*)\s*\(\s*\)\s*\.await")
|
|
2194
|
+
|
|
2195
|
+
# Track which lines are inside macro bodies
|
|
2196
|
+
in_macro = False
|
|
2197
|
+
macro_depth = 0
|
|
2198
|
+
|
|
2199
|
+
for lineno, line in enumerate(lines, 1):
|
|
2200
|
+
# Detect macro start (common async macros)
|
|
2201
|
+
if re.search(r"\b(tokio::select|tokio::spawn|async_std::task)", line):
|
|
2202
|
+
in_macro = True
|
|
2203
|
+
macro_depth = line.count("{") - line.count("}")
|
|
2204
|
+
|
|
2205
|
+
if in_macro:
|
|
2206
|
+
macro_depth += line.count("{") - line.count("}")
|
|
2207
|
+
if macro_depth <= 0:
|
|
2208
|
+
in_macro = False
|
|
2209
|
+
continue
|
|
2210
|
+
|
|
2211
|
+
# Find method calls in this line
|
|
2212
|
+
for pattern in [re_method_call, re_method_call_args, re_await_method]:
|
|
2213
|
+
for match in pattern.finditer(line):
|
|
2214
|
+
if pattern == re_await_method:
|
|
2215
|
+
method_name = match.group(1)
|
|
2216
|
+
receiver = None
|
|
2217
|
+
else:
|
|
2218
|
+
receiver = match.group(1)
|
|
2219
|
+
method_name = match.group(2)
|
|
2220
|
+
|
|
2221
|
+
# Skip stdlib methods
|
|
2222
|
+
if is_non_project_call(method_name):
|
|
2223
|
+
continue
|
|
2224
|
+
|
|
2225
|
+
# Skip if receiver is a common variable pattern
|
|
2226
|
+
if receiver in ("e", "err", "error", "_"):
|
|
2227
|
+
continue
|
|
2228
|
+
|
|
2229
|
+
context_id = line_to_context.get(lineno)
|
|
2230
|
+
if not context_id:
|
|
2231
|
+
for offset in range(1, 20):
|
|
2232
|
+
context_id = line_to_context.get(
|
|
2233
|
+
lineno - offset
|
|
2234
|
+
) or line_to_context.get(lineno + offset)
|
|
2235
|
+
if context_id:
|
|
2236
|
+
break
|
|
2237
|
+
|
|
2238
|
+
source_id = context_id if context_id else file_id
|
|
2239
|
+
|
|
2240
|
+
# Create qualified name if receiver is self
|
|
2241
|
+
if receiver == "self":
|
|
2242
|
+
callee = f"self.{method_name}"
|
|
2243
|
+
else:
|
|
2244
|
+
callee = method_name
|
|
2245
|
+
|
|
2246
|
+
self.builder.edges.append(
|
|
2247
|
+
{
|
|
2248
|
+
"source": source_id,
|
|
2249
|
+
"target": f"REF:{callee}",
|
|
2250
|
+
"relation": "CALLS",
|
|
2251
|
+
"metadata": {"lineno": lineno, "via": "macro"},
|
|
2252
|
+
}
|
|
2253
|
+
)
|
|
2254
|
+
|
|
2255
|
+
def _consume_until_body(self, lines, start_idx):
|
|
2256
|
+
sig_lines = [lines[start_idx]]
|
|
2257
|
+
j = start_idx
|
|
2258
|
+
open_p = lines[j].count("(")
|
|
2259
|
+
close_p = lines[j].count(")")
|
|
2260
|
+
while (
|
|
2261
|
+
open_p > close_p or ("{" not in lines[j] and ";" not in lines[j])
|
|
2262
|
+
) and j < len(lines) - 1:
|
|
2263
|
+
j += 1
|
|
2264
|
+
sig_lines.append(lines[j])
|
|
2265
|
+
open_p += lines[j].count("(")
|
|
2266
|
+
close_p += lines[j].count(")")
|
|
2267
|
+
return j, " ".join([ln.strip() for ln in sig_lines])
|
|
2268
|
+
|
|
2269
|
+
def _extract_rust_param_types(self, full_sig: str) -> list:
|
|
2270
|
+
"""Extract parameter types from Rust function signature.
|
|
2271
|
+
|
|
2272
|
+
Uses lightweight heuristics to extract types without full parsing.
|
|
2273
|
+
Handles common patterns like:
|
|
2274
|
+
- &self, &mut self
|
|
2275
|
+
- name: Type
|
|
2276
|
+
- name: &Type
|
|
2277
|
+
- name: impl Trait
|
|
2278
|
+
- name: Box<T>
|
|
2279
|
+
"""
|
|
2280
|
+
param_types = []
|
|
2281
|
+
try:
|
|
2282
|
+
# Extract content between first ( and matching )
|
|
2283
|
+
paren_start = full_sig.find("(")
|
|
2284
|
+
if paren_start == -1:
|
|
2285
|
+
return []
|
|
2286
|
+
|
|
2287
|
+
# Find matching close paren (handle nested)
|
|
2288
|
+
depth = 0
|
|
2289
|
+
paren_end = -1
|
|
2290
|
+
for i, c in enumerate(full_sig[paren_start:], paren_start):
|
|
2291
|
+
if c == "(":
|
|
2292
|
+
depth += 1
|
|
2293
|
+
elif c == ")":
|
|
2294
|
+
depth -= 1
|
|
2295
|
+
if depth == 0:
|
|
2296
|
+
paren_end = i
|
|
2297
|
+
break
|
|
2298
|
+
|
|
2299
|
+
if paren_end == -1:
|
|
2300
|
+
return []
|
|
2301
|
+
|
|
2302
|
+
params_str = full_sig[paren_start + 1 : paren_end].strip()
|
|
2303
|
+
if not params_str:
|
|
2304
|
+
return []
|
|
2305
|
+
|
|
2306
|
+
# Split on comma, but not inside angle brackets or parens
|
|
2307
|
+
params = []
|
|
2308
|
+
current = []
|
|
2309
|
+
depth = 0
|
|
2310
|
+
for c in params_str:
|
|
2311
|
+
if c in "<(":
|
|
2312
|
+
depth += 1
|
|
2313
|
+
elif c in ">)":
|
|
2314
|
+
depth -= 1
|
|
2315
|
+
elif c == "," and depth == 0:
|
|
2316
|
+
params.append("".join(current).strip())
|
|
2317
|
+
current = []
|
|
2318
|
+
continue
|
|
2319
|
+
current.append(c)
|
|
2320
|
+
if current:
|
|
2321
|
+
params.append("".join(current).strip())
|
|
2322
|
+
|
|
2323
|
+
for param in params:
|
|
2324
|
+
param = param.strip()
|
|
2325
|
+
if not param:
|
|
2326
|
+
continue
|
|
2327
|
+
|
|
2328
|
+
# Handle &self, &mut self, self
|
|
2329
|
+
if param in ("self", "&self", "&mut self"):
|
|
2330
|
+
param_types.append(param)
|
|
2331
|
+
continue
|
|
2332
|
+
|
|
2333
|
+
# Handle name: Type pattern
|
|
2334
|
+
if ":" in param:
|
|
2335
|
+
parts = param.split(":", 1)
|
|
2336
|
+
if len(parts) == 2:
|
|
2337
|
+
type_part = parts[1].strip()
|
|
2338
|
+
# Simplify complex types for readability
|
|
2339
|
+
if len(type_part) > 50:
|
|
2340
|
+
type_part = type_part[:47] + "..."
|
|
2341
|
+
param_types.append(type_part)
|
|
2342
|
+
else:
|
|
2343
|
+
# No type annotation
|
|
2344
|
+
param_types.append("_")
|
|
2345
|
+
|
|
2346
|
+
except (IndexError, ValueError):
|
|
2347
|
+
pass
|
|
2348
|
+
|
|
2349
|
+
return param_types
|
|
2350
|
+
|
|
2351
|
+
|
|
2352
|
+
class ProtoParser(BaseParser):
|
|
2353
|
+
def parse(self, file_path, rel_path, content):
|
|
2354
|
+
file_id = f"FILE:{rel_path}"
|
|
2355
|
+
self.builder.add_node(
|
|
2356
|
+
file_id,
|
|
2357
|
+
"File",
|
|
2358
|
+
path=rel_path,
|
|
2359
|
+
name=Path(rel_path).name,
|
|
2360
|
+
token_count=len(content) // 4,
|
|
2361
|
+
)
|
|
2362
|
+
|
|
2363
|
+
lines = content.splitlines()
|
|
2364
|
+
current_context = [file_id]
|
|
2365
|
+
context_indent = [0]
|
|
2366
|
+
|
|
2367
|
+
re_service = re.compile(r"^\s*service\s+([a-zA-Z0-9_]+)")
|
|
2368
|
+
re_rpc = re.compile(
|
|
2369
|
+
r"^\s*rpc\s+([a-zA-Z0-9_]+)\s*\((.*?)\)\s*returns\s*\((.*?)\)"
|
|
2370
|
+
)
|
|
2371
|
+
re_message = re.compile(r"^\s*message\s+([a-zA-Z0-9_]+)")
|
|
2372
|
+
|
|
2373
|
+
for i, line in enumerate(lines):
|
|
2374
|
+
lineno = i + 1
|
|
2375
|
+
indent = len(line) - len(line.lstrip())
|
|
2376
|
+
|
|
2377
|
+
if "}" in line:
|
|
2378
|
+
while len(context_indent) > 1 and indent <= context_indent[-1]:
|
|
2379
|
+
context_indent.pop()
|
|
2380
|
+
current_context.pop()
|
|
2381
|
+
|
|
2382
|
+
parent_id = current_context[-1]
|
|
2383
|
+
|
|
2384
|
+
if match := re_service.search(line):
|
|
2385
|
+
name = match.group(1)
|
|
2386
|
+
docstring = self.get_leading_docstring(lines, i)
|
|
2387
|
+
sid = f"{parent_id}::{name}"
|
|
2388
|
+
self.builder.add_node(
|
|
2389
|
+
sid, "Class", name=name, lineno=lineno, docstring=docstring
|
|
2390
|
+
)
|
|
2391
|
+
self.builder.add_edge(parent_id, sid, "DEFINES")
|
|
2392
|
+
if "{" in line:
|
|
2393
|
+
current_context.append(sid)
|
|
2394
|
+
context_indent.append(indent)
|
|
2395
|
+
|
|
2396
|
+
elif match := re_rpc.search(line):
|
|
2397
|
+
name, req, res = match.groups()
|
|
2398
|
+
docstring = self.get_leading_docstring(lines, i)
|
|
2399
|
+
rid = f"{parent_id}::{name}"
|
|
2400
|
+
sig = f"rpc {name}({req}) returns ({res})"
|
|
2401
|
+
self.builder.add_node(
|
|
2402
|
+
rid,
|
|
2403
|
+
"Function",
|
|
2404
|
+
name=name,
|
|
2405
|
+
signature=sig,
|
|
2406
|
+
lineno=lineno,
|
|
2407
|
+
docstring=docstring,
|
|
2408
|
+
)
|
|
2409
|
+
self.builder.add_edge(parent_id, rid, "DEFINES")
|
|
2410
|
+
if req:
|
|
2411
|
+
self.builder.add_edge(rid, f"REF:{req}", "CALLS")
|
|
2412
|
+
if res:
|
|
2413
|
+
self.builder.add_edge(rid, f"REF:{res}", "CALLS")
|
|
2414
|
+
|
|
2415
|
+
elif match := re_message.search(line):
|
|
2416
|
+
name = match.group(1)
|
|
2417
|
+
docstring = self.get_leading_docstring(lines, i)
|
|
2418
|
+
mid = f"{parent_id}::{name}"
|
|
2419
|
+
self.builder.add_node(
|
|
2420
|
+
mid, "Class", name=name, lineno=lineno, docstring=docstring
|
|
2421
|
+
)
|
|
2422
|
+
self.builder.add_edge(parent_id, mid, "DEFINES")
|
|
2423
|
+
if "{" in line:
|
|
2424
|
+
current_context.append(mid)
|
|
2425
|
+
context_indent.append(indent)
|
|
2426
|
+
|
|
2427
|
+
|
|
2428
|
+
class TSParser(BaseParser):
|
|
2429
|
+
def parse(self, file_path, rel_path, content):
|
|
2430
|
+
file_id = f"FILE:{rel_path}"
|
|
2431
|
+
self.builder.add_node(
|
|
2432
|
+
file_id,
|
|
2433
|
+
"File",
|
|
2434
|
+
path=rel_path,
|
|
2435
|
+
name=Path(rel_path).name,
|
|
2436
|
+
token_count=len(content) // 4,
|
|
2437
|
+
)
|
|
2438
|
+
|
|
2439
|
+
lines = content.splitlines()
|
|
2440
|
+
current_context = [file_id]
|
|
2441
|
+
|
|
2442
|
+
# Initialize TypeScript symbol table for import tracking
|
|
2443
|
+
self.symbol_table = TypeScriptSymbolTable(
|
|
2444
|
+
str(file_path), str(self.builder.root_dir)
|
|
2445
|
+
)
|
|
2446
|
+
|
|
2447
|
+
# Extract imports using ast-grep if available
|
|
2448
|
+
if USE_AST_GREP and extract_imports_typescript is not None:
|
|
2449
|
+
try:
|
|
2450
|
+
import_data = extract_imports_typescript(str(file_path))
|
|
2451
|
+
self.symbol_table.load_imports(import_data)
|
|
2452
|
+
except Exception:
|
|
2453
|
+
pass # Fall back to unqualified calls
|
|
2454
|
+
|
|
2455
|
+
re_class = re.compile(
|
|
2456
|
+
r"^\s*(?:export\s+)?(?:abstract\s+)?class\s+([a-zA-Z0-9_]+)"
|
|
2457
|
+
)
|
|
2458
|
+
re_interface = re.compile(r"^\s*(?:export\s+)?interface\s+([a-zA-Z0-9_]+)")
|
|
2459
|
+
re_func = re.compile(
|
|
2460
|
+
r"^\s*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z0-9_]+)"
|
|
2461
|
+
)
|
|
2462
|
+
re_method = re.compile(
|
|
2463
|
+
r"^\s*(?:private|public|protected)?\s*(?:static\s+)?(?:async\s+)?([a-zA-Z0-9_]+)\s*(?:<[^>]*>)?\s*[(]"
|
|
2464
|
+
)
|
|
2465
|
+
# Getter/setter methods: get name() or set name(value)
|
|
2466
|
+
re_getter_setter = re.compile(
|
|
2467
|
+
r"^\s*(?:private|public|protected)?\s*(?:static\s+)?(get|set)\s+([a-zA-Z0-9_]+)\s*[(]"
|
|
2468
|
+
)
|
|
2469
|
+
# D.4: merged alternation to avoid ReDoS. Previous pattern
|
|
2470
|
+
# (?:[^)]*|[^=]*) had overlapping unbounded alternatives which could
|
|
2471
|
+
# produce catastrophic backtracking on pathological long lines.
|
|
2472
|
+
re_const_arrow = re.compile(
|
|
2473
|
+
r"^\s*(?:export\s+)?const\s+([a-zA-Z0-9_]+)\s*=\s*(?:async\s*)?[^)=]*\s*=>"
|
|
2474
|
+
)
|
|
2475
|
+
re_enum = re.compile(r"^\s*(?:export\s+)?enum\s+([a-zA-Z0-9_]+)")
|
|
2476
|
+
re_const = re.compile(
|
|
2477
|
+
r"^\s*(?:export\s+)?const\s+([a-zA-Z0-9_]+)\s*(?::\s*.*?)?\s*=\s*(.*?);"
|
|
2478
|
+
)
|
|
2479
|
+
re_import = re.compile(r'import\s+.*?from\s+([\'"])(.*?)\1')
|
|
2480
|
+
re_call_candidate = re.compile(
|
|
2481
|
+
r"([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*)\s*(?=[<(])"
|
|
2482
|
+
)
|
|
2483
|
+
# Configuration patterns: interceptors, middleware, event handlers
|
|
2484
|
+
# These are method calls that set up important runtime behavior
|
|
2485
|
+
re_interceptor = re.compile(
|
|
2486
|
+
r"^\s*([a-zA-Z0-9_]+)\.interceptors\.(request|response)\.use\s*\("
|
|
2487
|
+
)
|
|
2488
|
+
re_middleware = re.compile(
|
|
2489
|
+
r"^\s*(?:app|router|server)\.use\s*\(\s*(['\"]?)([^'\"(),]+)\1"
|
|
2490
|
+
)
|
|
2491
|
+
re_event_handler = re.compile(
|
|
2492
|
+
r"^\s*([a-zA-Z0-9_]+)\.on\s*\(\s*['\"]([a-zA-Z0-9_:]+)['\"]"
|
|
2493
|
+
)
|
|
2494
|
+
|
|
2495
|
+
brace_balance = 0
|
|
2496
|
+
i = 0
|
|
2497
|
+
while i < len(lines):
|
|
2498
|
+
line = lines[i]
|
|
2499
|
+
lineno = i + 1
|
|
2500
|
+
|
|
2501
|
+
brace_balance += line.count("{") - line.count("}")
|
|
2502
|
+
if brace_balance < len(current_context) - 1:
|
|
2503
|
+
if len(current_context) > 1:
|
|
2504
|
+
current_context.pop()
|
|
2505
|
+
|
|
2506
|
+
parent_id = current_context[-1]
|
|
2507
|
+
|
|
2508
|
+
def consume_sig(idx):
|
|
2509
|
+
nonlocal brace_balance
|
|
2510
|
+
sig_lines = [lines[idx]]
|
|
2511
|
+
j = idx
|
|
2512
|
+
while (
|
|
2513
|
+
"{" not in lines[j] and ";" not in lines[j] and j < len(lines) - 1
|
|
2514
|
+
):
|
|
2515
|
+
j += 1
|
|
2516
|
+
sig_lines.append(lines[j])
|
|
2517
|
+
brace_balance += lines[j].count("{") - lines[j].count("}")
|
|
2518
|
+
return j, " ".join([ln.strip() for ln in sig_lines])
|
|
2519
|
+
|
|
2520
|
+
if match := re_const.search(line):
|
|
2521
|
+
name, val = match.groups()
|
|
2522
|
+
doc = self.get_leading_docstring(lines, i)
|
|
2523
|
+
cid = f"{parent_id}::{name}"
|
|
2524
|
+
self.builder.add_node(
|
|
2525
|
+
cid, "Constant", name=name, value=val, lineno=lineno, docstring=doc
|
|
2526
|
+
)
|
|
2527
|
+
self.builder.add_edge(parent_id, cid, "DEFINES")
|
|
2528
|
+
elif match := re_enum.search(line):
|
|
2529
|
+
name = match.group(1)
|
|
2530
|
+
doc = self.get_leading_docstring(lines, i)
|
|
2531
|
+
cid = f"{parent_id}::{name}"
|
|
2532
|
+
self.builder.add_node(
|
|
2533
|
+
cid, "Class", name=name, lineno=lineno, docstring=doc
|
|
2534
|
+
)
|
|
2535
|
+
self.builder.add_edge(parent_id, cid, "DEFINES")
|
|
2536
|
+
if "{" in line:
|
|
2537
|
+
current_context.append(cid)
|
|
2538
|
+
elif match := re_class.search(line):
|
|
2539
|
+
name = match.group(1)
|
|
2540
|
+
docstring = self.get_leading_docstring(lines, i)
|
|
2541
|
+
j, full_sig = consume_sig(i)
|
|
2542
|
+
cid = f"{parent_id}::{name}"
|
|
2543
|
+
# Find end of class block
|
|
2544
|
+
end_lineno = (
|
|
2545
|
+
self._find_block_end(lines, j) if "{" in lines[j] else lineno
|
|
2546
|
+
)
|
|
2547
|
+
token_count = (end_lineno - lineno + 1) * 10
|
|
2548
|
+
self.builder.add_node(
|
|
2549
|
+
cid,
|
|
2550
|
+
"Class",
|
|
2551
|
+
name=name,
|
|
2552
|
+
lineno=lineno,
|
|
2553
|
+
end_lineno=end_lineno,
|
|
2554
|
+
token_count=token_count,
|
|
2555
|
+
docstring=docstring,
|
|
2556
|
+
)
|
|
2557
|
+
self.builder.add_edge(parent_id, cid, "DEFINES")
|
|
2558
|
+
if "extends" in full_sig:
|
|
2559
|
+
try:
|
|
2560
|
+
base = full_sig.split("extends")[1].split()[0].split("{")[0]
|
|
2561
|
+
self.builder.add_edge(cid, f"REF:{base}", "INHERITS")
|
|
2562
|
+
except (IndexError, ValueError):
|
|
2563
|
+
pass
|
|
2564
|
+
if "{" in lines[j]:
|
|
2565
|
+
current_context.append(cid)
|
|
2566
|
+
i = j
|
|
2567
|
+
elif match := re_interface.search(line):
|
|
2568
|
+
name = match.group(1)
|
|
2569
|
+
docstring = self.get_leading_docstring(lines, i)
|
|
2570
|
+
j, full_sig = consume_sig(i)
|
|
2571
|
+
cid = f"{parent_id}::{name}"
|
|
2572
|
+
# Find end of interface block
|
|
2573
|
+
end_lineno = (
|
|
2574
|
+
self._find_block_end(lines, j) if "{" in lines[j] else lineno
|
|
2575
|
+
)
|
|
2576
|
+
token_count = (end_lineno - lineno + 1) * 10
|
|
2577
|
+
self.builder.add_node(
|
|
2578
|
+
cid,
|
|
2579
|
+
"Class",
|
|
2580
|
+
name=name,
|
|
2581
|
+
lineno=lineno,
|
|
2582
|
+
end_lineno=end_lineno,
|
|
2583
|
+
token_count=token_count,
|
|
2584
|
+
docstring=docstring,
|
|
2585
|
+
)
|
|
2586
|
+
self.builder.add_edge(parent_id, cid, "DEFINES")
|
|
2587
|
+
if "extends" in full_sig:
|
|
2588
|
+
try:
|
|
2589
|
+
bases = full_sig.split("extends")[1].split("{")[0].split(",")
|
|
2590
|
+
for b in bases:
|
|
2591
|
+
self.builder.add_edge(cid, f"REF:{b.strip()}", "INHERITS")
|
|
2592
|
+
except (IndexError, ValueError):
|
|
2593
|
+
pass
|
|
2594
|
+
if "{" in lines[j]:
|
|
2595
|
+
current_context.append(cid)
|
|
2596
|
+
i = j
|
|
2597
|
+
elif match := re_func.search(line):
|
|
2598
|
+
name = match.group(1)
|
|
2599
|
+
docstring = self.get_leading_docstring(lines, i)
|
|
2600
|
+
j, full_sig = consume_sig(i)
|
|
2601
|
+
fid = f"{parent_id}::{name}"
|
|
2602
|
+
return_type, param_types = self._extract_ts_types(full_sig)
|
|
2603
|
+
end_lineno = (
|
|
2604
|
+
self._find_block_end(lines, j) if "{" in lines[j] else lineno
|
|
2605
|
+
)
|
|
2606
|
+
signature = self._build_ts_signature(name, param_types, return_type)
|
|
2607
|
+
token_count = (end_lineno - lineno + 1) * 10
|
|
2608
|
+
self.builder.add_node(
|
|
2609
|
+
fid,
|
|
2610
|
+
"Function",
|
|
2611
|
+
name=name,
|
|
2612
|
+
signature=signature,
|
|
2613
|
+
lineno=lineno,
|
|
2614
|
+
end_lineno=end_lineno,
|
|
2615
|
+
token_count=token_count,
|
|
2616
|
+
docstring=docstring,
|
|
2617
|
+
return_type=return_type,
|
|
2618
|
+
param_types=param_types,
|
|
2619
|
+
)
|
|
2620
|
+
self.builder.add_edge(parent_id, fid, "DEFINES")
|
|
2621
|
+
if "{" in lines[j]:
|
|
2622
|
+
current_context.append(fid)
|
|
2623
|
+
i = j
|
|
2624
|
+
elif match := re_const_arrow.search(line):
|
|
2625
|
+
name = match.group(1)
|
|
2626
|
+
docstring = self.get_leading_docstring(lines, i)
|
|
2627
|
+
fid = f"{parent_id}::{name}"
|
|
2628
|
+
end_lineno = self._find_block_end(lines, i) if "{" in line else lineno
|
|
2629
|
+
# Arrow functions don't have easily extractable signatures
|
|
2630
|
+
signature = f"const {name} = (...) => ..."
|
|
2631
|
+
token_count = (end_lineno - lineno + 1) * 10
|
|
2632
|
+
self.builder.add_node(
|
|
2633
|
+
fid,
|
|
2634
|
+
"Function",
|
|
2635
|
+
name=name,
|
|
2636
|
+
signature=signature,
|
|
2637
|
+
lineno=lineno,
|
|
2638
|
+
end_lineno=end_lineno,
|
|
2639
|
+
token_count=token_count,
|
|
2640
|
+
docstring=docstring,
|
|
2641
|
+
)
|
|
2642
|
+
self.builder.add_edge(parent_id, fid, "DEFINES")
|
|
2643
|
+
if "{" in line:
|
|
2644
|
+
current_context.append(fid)
|
|
2645
|
+
elif (
|
|
2646
|
+
"::" in parent_id
|
|
2647
|
+
and parent_id != file_id
|
|
2648
|
+
and (match := re_method.search(line))
|
|
2649
|
+
):
|
|
2650
|
+
name = match.group(1)
|
|
2651
|
+
if name not in ["if", "for", "while", "switch", "catch"]:
|
|
2652
|
+
docstring = self.get_leading_docstring(lines, i)
|
|
2653
|
+
j, full_sig = consume_sig(i)
|
|
2654
|
+
mid = f"{parent_id}::{name}"
|
|
2655
|
+
return_type, param_types = self._extract_ts_types(full_sig)
|
|
2656
|
+
end_lineno = (
|
|
2657
|
+
self._find_block_end(lines, j) if "{" in lines[j] else lineno
|
|
2658
|
+
)
|
|
2659
|
+
# Build signature like: function name(p1: T1, p2: T2): ReturnType
|
|
2660
|
+
signature = self._build_ts_signature(name, param_types, return_type)
|
|
2661
|
+
token_count = (end_lineno - lineno + 1) * 10
|
|
2662
|
+
self.builder.add_node(
|
|
2663
|
+
mid,
|
|
2664
|
+
"Method",
|
|
2665
|
+
name=name,
|
|
2666
|
+
signature=signature,
|
|
2667
|
+
lineno=lineno,
|
|
2668
|
+
end_lineno=end_lineno,
|
|
2669
|
+
token_count=token_count,
|
|
2670
|
+
docstring=docstring,
|
|
2671
|
+
return_type=return_type,
|
|
2672
|
+
param_types=param_types,
|
|
2673
|
+
)
|
|
2674
|
+
self.builder.add_edge(parent_id, mid, "DEFINES")
|
|
2675
|
+
if "{" in lines[j]:
|
|
2676
|
+
current_context.append(mid)
|
|
2677
|
+
i = j
|
|
2678
|
+
# Handle getter/setter methods: get name() or set name(value)
|
|
2679
|
+
# Only match when parent is a Class (not inside a function/method)
|
|
2680
|
+
elif (
|
|
2681
|
+
"::" in parent_id
|
|
2682
|
+
and parent_id != file_id
|
|
2683
|
+
and self._is_class_context(parent_id)
|
|
2684
|
+
and (match := re_getter_setter.search(line))
|
|
2685
|
+
):
|
|
2686
|
+
accessor_type = match.group(1) # "get" or "set"
|
|
2687
|
+
name = match.group(2)
|
|
2688
|
+
docstring = self.get_leading_docstring(lines, i)
|
|
2689
|
+
j, full_sig = consume_sig(i)
|
|
2690
|
+
# Include accessor type in ID to distinguish get/set pairs
|
|
2691
|
+
mid = f"{parent_id}::{accessor_type}_{name}"
|
|
2692
|
+
return_type, param_types = self._extract_ts_types(full_sig)
|
|
2693
|
+
end_lineno = (
|
|
2694
|
+
self._find_block_end(lines, j) if "{" in lines[j] else lineno
|
|
2695
|
+
)
|
|
2696
|
+
# Build signature for getter/setter
|
|
2697
|
+
if accessor_type == "get":
|
|
2698
|
+
signature = f"get {name}(): {return_type}"
|
|
2699
|
+
else:
|
|
2700
|
+
param_str = ", ".join(param_types) if param_types else "value"
|
|
2701
|
+
signature = f"set {name}({param_str})"
|
|
2702
|
+
token_count = (end_lineno - lineno + 1) * 10
|
|
2703
|
+
self.builder.add_node(
|
|
2704
|
+
mid,
|
|
2705
|
+
"Method",
|
|
2706
|
+
name=name,
|
|
2707
|
+
signature=signature,
|
|
2708
|
+
lineno=lineno,
|
|
2709
|
+
end_lineno=end_lineno,
|
|
2710
|
+
token_count=token_count,
|
|
2711
|
+
docstring=docstring,
|
|
2712
|
+
return_type=return_type,
|
|
2713
|
+
param_types=param_types,
|
|
2714
|
+
accessor=accessor_type,
|
|
2715
|
+
)
|
|
2716
|
+
self.builder.add_edge(parent_id, mid, "DEFINES")
|
|
2717
|
+
# Don't push getters/setters to context - they're leaf methods
|
|
2718
|
+
# that shouldn't contain nested definitions
|
|
2719
|
+
i = j
|
|
2720
|
+
elif match := re_import.search(line):
|
|
2721
|
+
self.builder.add_edge(file_id, f"MODULE:{match.group(2)}", "IMPORTS")
|
|
2722
|
+
|
|
2723
|
+
# Configuration patterns - capture as Configuration nodes
|
|
2724
|
+
# These are important setup calls that often need to be found during investigation
|
|
2725
|
+
if match := re_interceptor.search(line):
|
|
2726
|
+
client_name = match.group(1)
|
|
2727
|
+
interceptor_type = match.group(2)
|
|
2728
|
+
config_name = f"{client_name}_{interceptor_type}_interceptor"
|
|
2729
|
+
config_id = f"{file_id}::{config_name}"
|
|
2730
|
+
# Find the callback's end by tracking braces
|
|
2731
|
+
end_lineno = (
|
|
2732
|
+
self._find_block_end(lines, i)
|
|
2733
|
+
if "{" in line or "=>" in line
|
|
2734
|
+
else lineno
|
|
2735
|
+
)
|
|
2736
|
+
# Look for next few lines if arrow function continues
|
|
2737
|
+
if end_lineno == lineno:
|
|
2738
|
+
for j in range(i + 1, min(i + 5, len(lines))):
|
|
2739
|
+
if "{" in lines[j] or "=>" in lines[j]:
|
|
2740
|
+
end_lineno = self._find_block_end(lines, j)
|
|
2741
|
+
break
|
|
2742
|
+
token_count = max((end_lineno - lineno + 1) * 10, 50)
|
|
2743
|
+
docstring = self.get_leading_docstring(lines, i)
|
|
2744
|
+
self.builder.add_node(
|
|
2745
|
+
config_id,
|
|
2746
|
+
"Configuration",
|
|
2747
|
+
name=config_name,
|
|
2748
|
+
signature=f"{client_name}.interceptors.{interceptor_type}.use(...)",
|
|
2749
|
+
lineno=lineno,
|
|
2750
|
+
end_lineno=end_lineno,
|
|
2751
|
+
token_count=token_count,
|
|
2752
|
+
docstring=docstring
|
|
2753
|
+
or f"Configures {interceptor_type} interceptor for {client_name}",
|
|
2754
|
+
config_type="interceptor",
|
|
2755
|
+
target=client_name,
|
|
2756
|
+
)
|
|
2757
|
+
self.builder.add_edge(file_id, config_id, "DEFINES")
|
|
2758
|
+
elif match := re_middleware.search(line):
|
|
2759
|
+
middleware_name = match.group(2).strip()
|
|
2760
|
+
if middleware_name and not middleware_name.startswith("("):
|
|
2761
|
+
config_name = f"middleware_{middleware_name.replace('/', '_').replace('.', '_')}"
|
|
2762
|
+
config_id = f"{file_id}::{config_name}"
|
|
2763
|
+
end_lineno = (
|
|
2764
|
+
self._find_block_end(lines, i) if "{" in line else lineno
|
|
2765
|
+
)
|
|
2766
|
+
token_count = max((end_lineno - lineno + 1) * 10, 30)
|
|
2767
|
+
self.builder.add_node(
|
|
2768
|
+
config_id,
|
|
2769
|
+
"Configuration",
|
|
2770
|
+
name=config_name,
|
|
2771
|
+
signature=f"app.use({middleware_name})",
|
|
2772
|
+
lineno=lineno,
|
|
2773
|
+
end_lineno=end_lineno,
|
|
2774
|
+
token_count=token_count,
|
|
2775
|
+
docstring=f"Registers middleware: {middleware_name}",
|
|
2776
|
+
config_type="middleware",
|
|
2777
|
+
)
|
|
2778
|
+
self.builder.add_edge(file_id, config_id, "DEFINES")
|
|
2779
|
+
elif match := re_event_handler.search(line):
|
|
2780
|
+
emitter_name = match.group(1)
|
|
2781
|
+
event_name = match.group(2)
|
|
2782
|
+
config_name = f"{emitter_name}_on_{event_name.replace(':', '_')}"
|
|
2783
|
+
config_id = f"{file_id}::{config_name}"
|
|
2784
|
+
end_lineno = (
|
|
2785
|
+
self._find_block_end(lines, i)
|
|
2786
|
+
if "{" in line or "=>" in line
|
|
2787
|
+
else lineno
|
|
2788
|
+
)
|
|
2789
|
+
token_count = max((end_lineno - lineno + 1) * 10, 30)
|
|
2790
|
+
docstring = self.get_leading_docstring(lines, i)
|
|
2791
|
+
self.builder.add_node(
|
|
2792
|
+
config_id,
|
|
2793
|
+
"Configuration",
|
|
2794
|
+
name=config_name,
|
|
2795
|
+
signature=f"{emitter_name}.on('{event_name}', ...)",
|
|
2796
|
+
lineno=lineno,
|
|
2797
|
+
end_lineno=end_lineno,
|
|
2798
|
+
token_count=token_count,
|
|
2799
|
+
docstring=docstring
|
|
2800
|
+
or f"Handles '{event_name}' event on {emitter_name}",
|
|
2801
|
+
config_type="event_handler",
|
|
2802
|
+
target=emitter_name,
|
|
2803
|
+
event=event_name,
|
|
2804
|
+
)
|
|
2805
|
+
self.builder.add_edge(file_id, config_id, "DEFINES")
|
|
2806
|
+
|
|
2807
|
+
# Calls (regex fallback - only used if ast-grep unavailable)
|
|
2808
|
+
if not USE_AST_GREP and parent_id != file_id:
|
|
2809
|
+
for match in re_call_candidate.finditer(line):
|
|
2810
|
+
callee = match.group(1)
|
|
2811
|
+
if is_non_project_call(callee):
|
|
2812
|
+
continue
|
|
2813
|
+
rem = line[match.end() :].lstrip()
|
|
2814
|
+
is_call = False
|
|
2815
|
+
if rem.startswith("("):
|
|
2816
|
+
is_call = True
|
|
2817
|
+
elif rem.startswith("<"):
|
|
2818
|
+
bal = 0
|
|
2819
|
+
for idx, c in enumerate(rem):
|
|
2820
|
+
if c == "<":
|
|
2821
|
+
bal += 1
|
|
2822
|
+
elif c == ">":
|
|
2823
|
+
bal -= 1
|
|
2824
|
+
if bal == 0:
|
|
2825
|
+
if rem[idx + 1 :].lstrip().startswith("("):
|
|
2826
|
+
is_call = True
|
|
2827
|
+
break
|
|
2828
|
+
if is_call:
|
|
2829
|
+
self.builder.edges.append(
|
|
2830
|
+
{
|
|
2831
|
+
"source": parent_id,
|
|
2832
|
+
"target": f"REF:{callee}",
|
|
2833
|
+
"relation": "CALLS",
|
|
2834
|
+
"metadata": {"lineno": lineno},
|
|
2835
|
+
}
|
|
2836
|
+
)
|
|
2837
|
+
i += 1
|
|
2838
|
+
|
|
2839
|
+
# Use ast-grep for more accurate CALLS detection
|
|
2840
|
+
if USE_AST_GREP:
|
|
2841
|
+
self._extract_calls_with_ast_grep(str(file_path), file_id, lines)
|
|
2842
|
+
|
|
2843
|
+
def _extract_ts_types(self, full_sig: str) -> tuple:
|
|
2844
|
+
"""Extract return type and parameter types from TypeScript function signature.
|
|
2845
|
+
|
|
2846
|
+
Returns: (return_type: str, param_types: list[str])
|
|
2847
|
+
|
|
2848
|
+
Handles common patterns:
|
|
2849
|
+
- function foo(x: string): number
|
|
2850
|
+
- async function bar(): Promise<void>
|
|
2851
|
+
- (a: Type, b: Type) => ReturnType
|
|
2852
|
+
"""
|
|
2853
|
+
return_type = "void"
|
|
2854
|
+
param_types = []
|
|
2855
|
+
|
|
2856
|
+
try:
|
|
2857
|
+
# Extract return type (after closing paren, before opening brace)
|
|
2858
|
+
# Pattern: ): ReturnType { or ): ReturnType =>
|
|
2859
|
+
ret_match = re.search(r"\)\s*:\s*([^{=]+?)(?:\s*[{=]|$)", full_sig)
|
|
2860
|
+
if ret_match:
|
|
2861
|
+
return_type = ret_match.group(1).strip()
|
|
2862
|
+
if len(return_type) > 50:
|
|
2863
|
+
return_type = return_type[:47] + "..."
|
|
2864
|
+
|
|
2865
|
+
# Extract parameters
|
|
2866
|
+
paren_match = re.search(r"\(([^)]*)\)", full_sig)
|
|
2867
|
+
if paren_match:
|
|
2868
|
+
params_str = paren_match.group(1).strip()
|
|
2869
|
+
if params_str:
|
|
2870
|
+
# Split on comma, respecting nested generics
|
|
2871
|
+
params = []
|
|
2872
|
+
current = []
|
|
2873
|
+
depth = 0
|
|
2874
|
+
for c in params_str:
|
|
2875
|
+
if c in "<([{":
|
|
2876
|
+
depth += 1
|
|
2877
|
+
elif c in ">)]}":
|
|
2878
|
+
depth -= 1
|
|
2879
|
+
elif c == "," and depth == 0:
|
|
2880
|
+
params.append("".join(current).strip())
|
|
2881
|
+
current = []
|
|
2882
|
+
continue
|
|
2883
|
+
current.append(c)
|
|
2884
|
+
if current:
|
|
2885
|
+
params.append("".join(current).strip())
|
|
2886
|
+
|
|
2887
|
+
for param in params:
|
|
2888
|
+
param = param.strip()
|
|
2889
|
+
if not param:
|
|
2890
|
+
continue
|
|
2891
|
+
# Handle name: Type pattern
|
|
2892
|
+
if ":" in param:
|
|
2893
|
+
parts = param.split(":", 1)
|
|
2894
|
+
if len(parts) == 2:
|
|
2895
|
+
type_part = parts[1].strip()
|
|
2896
|
+
# Remove default values
|
|
2897
|
+
if "=" in type_part:
|
|
2898
|
+
type_part = type_part.split("=")[0].strip()
|
|
2899
|
+
if len(type_part) > 40:
|
|
2900
|
+
type_part = type_part[:37] + "..."
|
|
2901
|
+
param_types.append(type_part)
|
|
2902
|
+
else:
|
|
2903
|
+
param_types.append("any")
|
|
2904
|
+
|
|
2905
|
+
except (IndexError, ValueError):
|
|
2906
|
+
pass
|
|
2907
|
+
|
|
2908
|
+
return return_type, param_types
|
|
2909
|
+
|
|
2910
|
+
def _build_ts_signature(
|
|
2911
|
+
self, name: str, param_types: list, return_type: str
|
|
2912
|
+
) -> str:
|
|
2913
|
+
"""Build a TypeScript-style signature string.
|
|
2914
|
+
|
|
2915
|
+
Returns: "function name(p1: T1, p2: T2): ReturnType"
|
|
2916
|
+
"""
|
|
2917
|
+
if param_types:
|
|
2918
|
+
# Create param list with generic names if we only have types
|
|
2919
|
+
params = ", ".join(f"p{i}: {t}" for i, t in enumerate(param_types))
|
|
2920
|
+
else:
|
|
2921
|
+
params = ""
|
|
2922
|
+
return f"function {name}({params}): {return_type}"
|
|
2923
|
+
|
|
2924
|
+
def _is_class_context(self, parent_id: str) -> bool:
|
|
2925
|
+
"""Check if parent_id refers to a Class node (not Function/Method).
|
|
2926
|
+
|
|
2927
|
+
Used to distinguish class getters/setters from object literal ones.
|
|
2928
|
+
"""
|
|
2929
|
+
for node in self.builder.nodes:
|
|
2930
|
+
if node["id"] == parent_id:
|
|
2931
|
+
return node["type"] == "Class"
|
|
2932
|
+
return False
|
|
2933
|
+
|
|
2934
|
+
def _extract_calls_with_ast_grep(self, file_path: str, file_id: str, lines: list):
|
|
2935
|
+
"""Extract calls using ast-grep for higher accuracy.
|
|
2936
|
+
|
|
2937
|
+
Uses the TypeScriptSymbolTable to filter type-only imports.
|
|
2938
|
+
SCIP handles actual symbol resolution for both Rust and TypeScript.
|
|
2939
|
+
"""
|
|
2940
|
+
# Build a line-to-context map from our parsed structure
|
|
2941
|
+
# Uses span-size ordering to ensure innermost context wins for nested functions
|
|
2942
|
+
line_to_context = build_line_to_context_map(self.builder.nodes, file_id)
|
|
2943
|
+
|
|
2944
|
+
for call in extract_calls_typescript(file_path):
|
|
2945
|
+
lineno = call["lineno"]
|
|
2946
|
+
callee = call["callee"]
|
|
2947
|
+
|
|
2948
|
+
# Filter stdlib calls
|
|
2949
|
+
if is_non_project_call(callee):
|
|
2950
|
+
continue
|
|
2951
|
+
|
|
2952
|
+
# Skip type-only imports (they don't generate runtime calls)
|
|
2953
|
+
if hasattr(self, "symbol_table") and self.symbol_table.is_type_import(
|
|
2954
|
+
callee
|
|
2955
|
+
):
|
|
2956
|
+
continue
|
|
2957
|
+
|
|
2958
|
+
context_id = line_to_context.get(lineno)
|
|
2959
|
+
if not context_id:
|
|
2960
|
+
for offset in range(1, 10):
|
|
2961
|
+
context_id = line_to_context.get(
|
|
2962
|
+
lineno - offset
|
|
2963
|
+
) or line_to_context.get(lineno + offset)
|
|
2964
|
+
if context_id:
|
|
2965
|
+
break
|
|
2966
|
+
|
|
2967
|
+
# Fall back to file_id if no function context found (file-level calls)
|
|
2968
|
+
source_id = context_id if context_id else file_id
|
|
2969
|
+
self.builder.edges.append(
|
|
2970
|
+
{
|
|
2971
|
+
"source": source_id,
|
|
2972
|
+
"target": f"REF:{callee}",
|
|
2973
|
+
"relation": "CALLS",
|
|
2974
|
+
"metadata": {"lineno": lineno},
|
|
2975
|
+
}
|
|
2976
|
+
)
|
|
2977
|
+
|
|
2978
|
+
|
|
2979
|
+
class CodeGraphBuilder:
|
|
2980
|
+
def __init__(self, root_dir, excluded_dirs=None):
|
|
2981
|
+
self.root_dir = Path(root_dir).resolve()
|
|
2982
|
+
self.excluded_dirs = excluded_dirs or {
|
|
2983
|
+
"target",
|
|
2984
|
+
"node_modules",
|
|
2985
|
+
"dist",
|
|
2986
|
+
"build",
|
|
2987
|
+
"docs",
|
|
2988
|
+
"coverage",
|
|
2989
|
+
"demo-output",
|
|
2990
|
+
".beads",
|
|
2991
|
+
}
|
|
2992
|
+
self.nodes = []
|
|
2993
|
+
self.edges = []
|
|
2994
|
+
self.node_registry = set()
|
|
2995
|
+
self.current_file_id = None
|
|
2996
|
+
self.current_scope = SymbolTable()
|
|
2997
|
+
|
|
2998
|
+
def add_node(self, node_id, node_type, **metadata):
|
|
2999
|
+
if node_id not in self.node_registry:
|
|
3000
|
+
self.nodes.append({"id": node_id, "type": node_type, "metadata": metadata})
|
|
3001
|
+
self.node_registry.add(node_id)
|
|
3002
|
+
|
|
3003
|
+
def add_edge(self, source, target, relation, metadata=None):
|
|
3004
|
+
edge = {"source": source, "target": target, "relation": relation}
|
|
3005
|
+
if metadata:
|
|
3006
|
+
edge["metadata"] = metadata
|
|
3007
|
+
self.edges.append(edge)
|
|
3008
|
+
|
|
3009
|
+
def get_rel_path(self, path):
|
|
3010
|
+
# Always use forward-slash separators in node IDs so the graph is
|
|
3011
|
+
# cross-platform (node IDs generated on Windows remain readable and
|
|
3012
|
+
# queryable on Linux/macOS, and vice versa).
|
|
3013
|
+
try:
|
|
3014
|
+
return path.relative_to(self.root_dir).as_posix()
|
|
3015
|
+
except ValueError:
|
|
3016
|
+
return Path(path).as_posix()
|
|
3017
|
+
|
|
3018
|
+
def process_directory(self):
|
|
3019
|
+
# Resolve root once; reject entering any child directory whose resolved
|
|
3020
|
+
# path escapes the project root (defends against malicious symlinks).
|
|
3021
|
+
root_real = Path(self.root_dir).resolve()
|
|
3022
|
+
for root, dirs, files in os.walk(self.root_dir, followlinks=False):
|
|
3023
|
+
dirs.sort()
|
|
3024
|
+
files.sort()
|
|
3025
|
+
# Filter by name first (excluded dirs, dotfiles).
|
|
3026
|
+
dirs[:] = [
|
|
3027
|
+
d for d in dirs if not d.startswith(".") and d not in self.excluded_dirs
|
|
3028
|
+
]
|
|
3029
|
+
# Then filter by realpath containment — drop directories that are
|
|
3030
|
+
# symlinks pointing outside the project root.
|
|
3031
|
+
safe_dirs = []
|
|
3032
|
+
for d in dirs:
|
|
3033
|
+
try:
|
|
3034
|
+
child_real = (Path(root) / d).resolve(strict=False)
|
|
3035
|
+
if child_real == root_real or root_real in child_real.parents:
|
|
3036
|
+
safe_dirs.append(d)
|
|
3037
|
+
else:
|
|
3038
|
+
logger.warning(
|
|
3039
|
+
"Skipping %s — escapes project root via symlink",
|
|
3040
|
+
child_real,
|
|
3041
|
+
)
|
|
3042
|
+
except OSError:
|
|
3043
|
+
continue
|
|
3044
|
+
dirs[:] = safe_dirs
|
|
3045
|
+
for file in files:
|
|
3046
|
+
file_path = Path(root) / file
|
|
3047
|
+
# Same containment check for files (catches symlink files).
|
|
3048
|
+
try:
|
|
3049
|
+
file_real = file_path.resolve(strict=False)
|
|
3050
|
+
if not (file_real == root_real or root_real in file_real.parents):
|
|
3051
|
+
logger.warning(
|
|
3052
|
+
"Skipping %s — escapes project root via symlink",
|
|
3053
|
+
file_real,
|
|
3054
|
+
)
|
|
3055
|
+
continue
|
|
3056
|
+
except OSError:
|
|
3057
|
+
continue
|
|
3058
|
+
rel_path = self.get_rel_path(file_path)
|
|
3059
|
+
# Skip pathologically large files before opening. A hostile
|
|
3060
|
+
# repo with a multi-GB `.py` would otherwise OOM the
|
|
3061
|
+
# generator by pulling the whole buffer into memory.
|
|
3062
|
+
try:
|
|
3063
|
+
if file_path.stat().st_size > _MAX_SOURCE_FILE_BYTES:
|
|
3064
|
+
logger.warning(
|
|
3065
|
+
"Skipping %s — %.1f MiB exceeds source cap",
|
|
3066
|
+
rel_path,
|
|
3067
|
+
file_path.stat().st_size / (1024 * 1024),
|
|
3068
|
+
)
|
|
3069
|
+
continue
|
|
3070
|
+
except OSError:
|
|
3071
|
+
continue
|
|
3072
|
+
# O_NOFOLLOW on the final open so a post-check symlink swap
|
|
3073
|
+
# can't redirect us outside the project root. Mirrors the
|
|
3074
|
+
# api_source hardening in web/server.py.
|
|
3075
|
+
try:
|
|
3076
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
3077
|
+
fd = os.open(str(file_path), flags)
|
|
3078
|
+
with os.fdopen(fd, "r", encoding="utf-8", errors="ignore") as f:
|
|
3079
|
+
content = f.read()
|
|
3080
|
+
except OSError as e:
|
|
3081
|
+
logger.warning(f"Cannot read {rel_path}: {e.strerror}")
|
|
3082
|
+
continue
|
|
3083
|
+
except Exception as e:
|
|
3084
|
+
logger.warning(f"Error reading {rel_path}: {e}")
|
|
3085
|
+
continue
|
|
3086
|
+
if file.endswith(".py"):
|
|
3087
|
+
PythonParser(self).parse(file_path, rel_path, content)
|
|
3088
|
+
elif file.endswith(".rs"):
|
|
3089
|
+
RustParser(self).parse(file_path, rel_path, content)
|
|
3090
|
+
elif file.endswith(".proto"):
|
|
3091
|
+
ProtoParser(self).parse(file_path, rel_path, content)
|
|
3092
|
+
elif (
|
|
3093
|
+
file.endswith(".ts")
|
|
3094
|
+
or file.endswith(".tsx")
|
|
3095
|
+
or file.endswith(".js")
|
|
3096
|
+
):
|
|
3097
|
+
TSParser(self).parse(file_path, rel_path, content)
|
|
3098
|
+
elif file.endswith(".svelte"):
|
|
3099
|
+
# D.2: iterate all <script> blocks and shift each block's
|
|
3100
|
+
# parsed linenos by its offset within the .svelte file.
|
|
3101
|
+
pos = 0
|
|
3102
|
+
while True:
|
|
3103
|
+
start_tag = content.find("<script", pos)
|
|
3104
|
+
if start_tag == -1:
|
|
3105
|
+
break
|
|
3106
|
+
gt = content.find(">", start_tag)
|
|
3107
|
+
if gt == -1:
|
|
3108
|
+
break
|
|
3109
|
+
close = content.find("</script>", gt)
|
|
3110
|
+
if close == -1:
|
|
3111
|
+
break
|
|
3112
|
+
body = content[gt + 1 : close]
|
|
3113
|
+
# Lines before the script body (the character after '>').
|
|
3114
|
+
line_offset = content.count("\n", 0, gt + 1)
|
|
3115
|
+
before = len(self.nodes)
|
|
3116
|
+
TSParser(self).parse(file_path, rel_path, body)
|
|
3117
|
+
# Shift newly-added node linenos by the offset.
|
|
3118
|
+
for node in self.nodes[before:]:
|
|
3119
|
+
meta = node.get("metadata", {})
|
|
3120
|
+
for k in ("lineno", "end_lineno"):
|
|
3121
|
+
v = meta.get(k)
|
|
3122
|
+
if isinstance(v, int):
|
|
3123
|
+
meta[k] = v + line_offset
|
|
3124
|
+
pos = close + len("</script>")
|
|
3125
|
+
|
|
3126
|
+
def _get_language(self, node_id: str) -> str:
|
|
3127
|
+
"""Extract language from node ID based on file extension."""
|
|
3128
|
+
# Node ID format: FILE:path/to/file.ext::Symbol
|
|
3129
|
+
if not node_id.startswith("FILE:"):
|
|
3130
|
+
return "unknown"
|
|
3131
|
+
file_part = node_id.split("::")[0].replace("FILE:", "")
|
|
3132
|
+
if file_part.endswith(".rs"):
|
|
3133
|
+
return "rust"
|
|
3134
|
+
elif file_part.endswith((".ts", ".tsx", ".js", ".jsx", ".svelte")):
|
|
3135
|
+
return "typescript"
|
|
3136
|
+
elif file_part.endswith(".py"):
|
|
3137
|
+
return "python"
|
|
3138
|
+
elif file_part.endswith(".proto"):
|
|
3139
|
+
return "proto"
|
|
3140
|
+
return "unknown"
|
|
3141
|
+
|
|
3142
|
+
def resolve_references(self, scip_index=None):
|
|
3143
|
+
"""Resolve REF: targets to actual node IDs with smart matching.
|
|
3144
|
+
|
|
3145
|
+
Resolution priorities:
|
|
3146
|
+
0. SCIP lookup (highest accuracy, type-aware)
|
|
3147
|
+
1. Same language (required for regex fallback)
|
|
3148
|
+
2. Same struct/class for self.method patterns
|
|
3149
|
+
3. Qualified name match (Type::method)
|
|
3150
|
+
4. Same file
|
|
3151
|
+
5. Any same-language match
|
|
3152
|
+
|
|
3153
|
+
Args:
|
|
3154
|
+
scip_index: Optional ScipIndex for type-aware resolution
|
|
3155
|
+
"""
|
|
3156
|
+
# Track resolution statistics
|
|
3157
|
+
stats = {"scip": 0, "regex": 0, "self_type": 0, "unresolved": 0}
|
|
3158
|
+
|
|
3159
|
+
# Build lookup tables for regex fallback
|
|
3160
|
+
# name -> [(node_id, language), ...]
|
|
3161
|
+
node_lookup = {}
|
|
3162
|
+
# qualified_name -> [(node_id, language), ...] (e.g., "Struct::method")
|
|
3163
|
+
qualified_lookup = {}
|
|
3164
|
+
|
|
3165
|
+
for node in self.nodes:
|
|
3166
|
+
nid = node["id"]
|
|
3167
|
+
parts = nid.split("::")
|
|
3168
|
+
if len(parts) >= 2:
|
|
3169
|
+
name_part = parts[-1]
|
|
3170
|
+
lang = self._get_language(nid)
|
|
3171
|
+
|
|
3172
|
+
# Simple name lookup
|
|
3173
|
+
if name_part not in node_lookup:
|
|
3174
|
+
node_lookup[name_part] = []
|
|
3175
|
+
node_lookup[name_part].append((nid, lang))
|
|
3176
|
+
|
|
3177
|
+
# Qualified name lookup (Struct::method)
|
|
3178
|
+
if len(parts) >= 3:
|
|
3179
|
+
# parts: ['FILE:path/file.rs', 'Struct', 'method']
|
|
3180
|
+
qualified = f"{parts[-2]}::{parts[-1]}"
|
|
3181
|
+
if qualified not in qualified_lookup:
|
|
3182
|
+
qualified_lookup[qualified] = []
|
|
3183
|
+
qualified_lookup[qualified].append((nid, lang))
|
|
3184
|
+
|
|
3185
|
+
# Resolve edges with smart matching
|
|
3186
|
+
edges_to_remove = set()
|
|
3187
|
+
for idx, edge in enumerate(self.edges):
|
|
3188
|
+
target = edge["target"]
|
|
3189
|
+
if target.startswith("REF:"):
|
|
3190
|
+
ref_name = target.replace("REF:", "")
|
|
3191
|
+
source_id = edge["source"]
|
|
3192
|
+
source_lang = self._get_language(source_id)
|
|
3193
|
+
|
|
3194
|
+
resolved = None
|
|
3195
|
+
resolution_source = None
|
|
3196
|
+
|
|
3197
|
+
# Pre-filter: skip non-project refs (stdlib/library) that slipped through parsing
|
|
3198
|
+
# These will never resolve and shouldn't count as unresolved
|
|
3199
|
+
if is_non_project_call(ref_name):
|
|
3200
|
+
edges_to_remove.add(idx)
|
|
3201
|
+
continue
|
|
3202
|
+
|
|
3203
|
+
# Strategy 0: SCIP lookup (highest accuracy; supports Rust,
|
|
3204
|
+
# TypeScript, and Python when the corresponding indexer ran).
|
|
3205
|
+
if scip_index and source_lang in ("rust", "typescript", "python"):
|
|
3206
|
+
# Get source file and line for SCIP lookup
|
|
3207
|
+
source_file = source_id.split("::")[0].replace("FILE:", "")
|
|
3208
|
+
lineno = edge.get("metadata", {}).get("lineno", 0)
|
|
3209
|
+
scip_resolved = scip_index.resolve(ref_name, source_file, lineno)
|
|
3210
|
+
if scip_resolved:
|
|
3211
|
+
# Validate cross-crate resolutions to prevent false positives
|
|
3212
|
+
# Extract crate names from paths (first directory component)
|
|
3213
|
+
source_crate = (
|
|
3214
|
+
source_file.split("/")[0] if "/" in source_file else ""
|
|
3215
|
+
)
|
|
3216
|
+
resolved_file = scip_resolved.split("::")[0].replace(
|
|
3217
|
+
"FILE:", ""
|
|
3218
|
+
)
|
|
3219
|
+
target_crate = (
|
|
3220
|
+
resolved_file.split("/")[0] if "/" in resolved_file else ""
|
|
3221
|
+
)
|
|
3222
|
+
|
|
3223
|
+
# If cross-crate, verify ref_name looks related to the target
|
|
3224
|
+
# This prevents spurious matches like EnvFilter -> AuthInterceptor
|
|
3225
|
+
if (
|
|
3226
|
+
source_crate
|
|
3227
|
+
and target_crate
|
|
3228
|
+
and source_crate != target_crate
|
|
3229
|
+
):
|
|
3230
|
+
# Extract the target symbol name
|
|
3231
|
+
target_parts = scip_resolved.split("::")
|
|
3232
|
+
target_name = target_parts[-1] if target_parts else ""
|
|
3233
|
+
|
|
3234
|
+
# Only accept if ref_name contains the target name or vice versa
|
|
3235
|
+
if target_name and target_name.lower() in ref_name.lower():
|
|
3236
|
+
resolved = scip_resolved
|
|
3237
|
+
resolution_source = "scip"
|
|
3238
|
+
# else: Skip this cross-crate match as likely false positive
|
|
3239
|
+
else:
|
|
3240
|
+
# Same crate or can't determine - accept the resolution
|
|
3241
|
+
resolved = scip_resolved
|
|
3242
|
+
resolution_source = "scip"
|
|
3243
|
+
|
|
3244
|
+
# Normalize crate:: prefix (Rust-specific)
|
|
3245
|
+
# crate::path::Symbol -> path::Symbol for matching
|
|
3246
|
+
if ref_name.startswith("crate::"):
|
|
3247
|
+
ref_name = ref_name[7:] # Remove "crate::"
|
|
3248
|
+
|
|
3249
|
+
# Strip trailing method chains for resolution
|
|
3250
|
+
# Handles: Type::new(&arg).unwrap -> Type::new
|
|
3251
|
+
# chrono::Utc::now().naive_utc -> chrono::Utc::now
|
|
3252
|
+
# TempDir::new().unwrap -> TempDir::new
|
|
3253
|
+
base_ref = ref_name
|
|
3254
|
+
if "::" in ref_name:
|
|
3255
|
+
# Match multi-segment qualified names (A::B or A::B::C etc.)
|
|
3256
|
+
# stopping before ( or . that follows the last segment
|
|
3257
|
+
chain_match = re.match(
|
|
3258
|
+
r"^((?:[A-Za-z_][A-Za-z0-9_]*::)+[A-Za-z_][A-Za-z0-9_]*)",
|
|
3259
|
+
ref_name,
|
|
3260
|
+
)
|
|
3261
|
+
if chain_match:
|
|
3262
|
+
base_ref = chain_match.group(1)
|
|
3263
|
+
# For lookup, also try just the last two segments
|
|
3264
|
+
# chrono::Utc::now -> Utc::now (which matches qualified_lookup)
|
|
3265
|
+
segments = base_ref.split("::")
|
|
3266
|
+
if len(segments) > 2:
|
|
3267
|
+
base_ref_short = f"{segments[-2]}::{segments[-1]}"
|
|
3268
|
+
if base_ref_short in qualified_lookup:
|
|
3269
|
+
base_ref = base_ref_short
|
|
3270
|
+
|
|
3271
|
+
# Strategy 1: Try qualified name match (Type::method or Type::Variant)
|
|
3272
|
+
# Use base_ref which has trailing method chains stripped
|
|
3273
|
+
if not resolved and "::" in base_ref and base_ref in qualified_lookup:
|
|
3274
|
+
matches = [
|
|
3275
|
+
m for m in qualified_lookup[base_ref] if m[1] == source_lang
|
|
3276
|
+
]
|
|
3277
|
+
if matches:
|
|
3278
|
+
resolved = matches[0][0]
|
|
3279
|
+
resolution_source = "regex"
|
|
3280
|
+
|
|
3281
|
+
# Strategy 1.5: Resolve Self::method to StructName::method
|
|
3282
|
+
# Self:: refers to the concrete struct of the enclosing impl block.
|
|
3283
|
+
# We infer the struct name from the source node ID (FILE:path::Struct::method).
|
|
3284
|
+
if not resolved and base_ref.startswith("Self::"):
|
|
3285
|
+
self_method = base_ref.replace("Self::", "", 1)
|
|
3286
|
+
source_parts = source_id.split("::")
|
|
3287
|
+
if len(source_parts) >= 3:
|
|
3288
|
+
source_struct = source_parts[-2]
|
|
3289
|
+
qualified_self = f"{source_struct}::{self_method}"
|
|
3290
|
+
if qualified_self in qualified_lookup:
|
|
3291
|
+
matches = [
|
|
3292
|
+
m
|
|
3293
|
+
for m in qualified_lookup[qualified_self]
|
|
3294
|
+
if m[1] == source_lang
|
|
3295
|
+
]
|
|
3296
|
+
if matches:
|
|
3297
|
+
resolved = matches[0][0]
|
|
3298
|
+
resolution_source = "self_type"
|
|
3299
|
+
# Also try via node_lookup with struct preference
|
|
3300
|
+
if not resolved and self_method in node_lookup:
|
|
3301
|
+
matches = [
|
|
3302
|
+
m
|
|
3303
|
+
for m in node_lookup[self_method]
|
|
3304
|
+
if m[1] == source_lang
|
|
3305
|
+
]
|
|
3306
|
+
struct_matches = [
|
|
3307
|
+
m
|
|
3308
|
+
for m in matches
|
|
3309
|
+
if f"::{source_struct}::{self_method}" in m[0]
|
|
3310
|
+
]
|
|
3311
|
+
if struct_matches:
|
|
3312
|
+
resolved = struct_matches[0][0]
|
|
3313
|
+
resolution_source = "self_type"
|
|
3314
|
+
|
|
3315
|
+
# Strategy 2: For self.method, prefer same-struct methods
|
|
3316
|
+
if not resolved and ref_name.startswith("self."):
|
|
3317
|
+
method_name = ref_name.replace("self.", "")
|
|
3318
|
+
if method_name in node_lookup:
|
|
3319
|
+
matches = [
|
|
3320
|
+
m for m in node_lookup[method_name] if m[1] == source_lang
|
|
3321
|
+
]
|
|
3322
|
+
if matches:
|
|
3323
|
+
# Extract struct name from source (FILE:path::Struct::method)
|
|
3324
|
+
source_parts = source_id.split("::")
|
|
3325
|
+
if len(source_parts) >= 3:
|
|
3326
|
+
source_struct = source_parts[-2]
|
|
3327
|
+
# Prefer methods in the same struct
|
|
3328
|
+
struct_matches = [
|
|
3329
|
+
m
|
|
3330
|
+
for m in matches
|
|
3331
|
+
if f"::{source_struct}::{method_name}" in m[0]
|
|
3332
|
+
]
|
|
3333
|
+
if struct_matches:
|
|
3334
|
+
resolved = struct_matches[0][0]
|
|
3335
|
+
else:
|
|
3336
|
+
resolved = matches[0][0]
|
|
3337
|
+
resolution_source = "regex"
|
|
3338
|
+
|
|
3339
|
+
# Strategy 3: Extract base name and try standard resolution
|
|
3340
|
+
if not resolved:
|
|
3341
|
+
# Get base name (last part after . or ::)
|
|
3342
|
+
base_name = ref_name.split(".")[-1].split("::")[-1]
|
|
3343
|
+
if base_name in node_lookup:
|
|
3344
|
+
matches = [
|
|
3345
|
+
m for m in node_lookup[base_name] if m[1] == source_lang
|
|
3346
|
+
]
|
|
3347
|
+
if matches:
|
|
3348
|
+
# Prefer same-file matches
|
|
3349
|
+
source_file = source_id.split("::")[0]
|
|
3350
|
+
same_file = [
|
|
3351
|
+
m for m in matches if m[0].startswith(source_file)
|
|
3352
|
+
]
|
|
3353
|
+
if same_file:
|
|
3354
|
+
resolved = same_file[0][0]
|
|
3355
|
+
else:
|
|
3356
|
+
resolved = matches[0][0]
|
|
3357
|
+
resolution_source = "regex"
|
|
3358
|
+
|
|
3359
|
+
if resolved:
|
|
3360
|
+
edge["target"] = resolved
|
|
3361
|
+
edge["metadata"] = edge.get("metadata", {})
|
|
3362
|
+
edge["metadata"]["resolution_source"] = resolution_source
|
|
3363
|
+
if edge["relation"] == "CALLS":
|
|
3364
|
+
edge["relation"] = "CALLS_RESOLVED"
|
|
3365
|
+
stats[resolution_source] += 1
|
|
3366
|
+
elif edge["relation"] == "INSTANTIATES":
|
|
3367
|
+
edge["relation"] = "INSTANTIATES_RESOLVED"
|
|
3368
|
+
elif edge["relation"] == "CALLS":
|
|
3369
|
+
stats["unresolved"] += 1
|
|
3370
|
+
|
|
3371
|
+
# Remove filtered non-project edges (stdlib + third-party library calls)
|
|
3372
|
+
if edges_to_remove:
|
|
3373
|
+
self.edges = [
|
|
3374
|
+
e for i, e in enumerate(self.edges) if i not in edges_to_remove
|
|
3375
|
+
]
|
|
3376
|
+
logger.info(
|
|
3377
|
+
f"Filtered {len(edges_to_remove)} non-project edges during resolution"
|
|
3378
|
+
)
|
|
3379
|
+
|
|
3380
|
+
# Log resolution statistics
|
|
3381
|
+
total = (
|
|
3382
|
+
stats["scip"] + stats["regex"] + stats["self_type"] + stats["unresolved"]
|
|
3383
|
+
)
|
|
3384
|
+
if total > 0:
|
|
3385
|
+
scip_pct = 100 * stats["scip"] / total if total else 0
|
|
3386
|
+
regex_pct = 100 * stats["regex"] / total if total else 0
|
|
3387
|
+
self_type_pct = 100 * stats["self_type"] / total if total else 0
|
|
3388
|
+
resolved_total = stats["scip"] + stats["regex"] + stats["self_type"]
|
|
3389
|
+
resolved_pct = 100 * resolved_total / total if total else 0
|
|
3390
|
+
logger.info(
|
|
3391
|
+
f"CALLS Resolution: {resolved_pct:.0f}% "
|
|
3392
|
+
f"(SCIP: {stats['scip']}/{scip_pct:.0f}%, "
|
|
3393
|
+
f"Regex: {stats['regex']}/{regex_pct:.0f}%, "
|
|
3394
|
+
f"Self: {stats['self_type']}/{self_type_pct:.0f}%, "
|
|
3395
|
+
f"Unresolved: {stats['unresolved']})"
|
|
3396
|
+
)
|
|
3397
|
+
|
|
3398
|
+
def export(self, output_path, scip_index=None):
|
|
3399
|
+
"""Export the graph to JSON.
|
|
3400
|
+
|
|
3401
|
+
Args:
|
|
3402
|
+
output_path: Path to write the graph JSON
|
|
3403
|
+
scip_index: Optional ScipIndex for type-aware resolution
|
|
3404
|
+
"""
|
|
3405
|
+
from descry._graph import CURRENT_SCHEMA
|
|
3406
|
+
|
|
3407
|
+
self.resolve_references(scip_index=scip_index)
|
|
3408
|
+
in_degree = {}
|
|
3409
|
+
for edge in self.edges:
|
|
3410
|
+
t = edge["target"]
|
|
3411
|
+
if t:
|
|
3412
|
+
in_degree[t] = in_degree.get(t, 0) + 1
|
|
3413
|
+
for node in self.nodes:
|
|
3414
|
+
node["metadata"]["in_degree"] = in_degree.get(node["id"], 0)
|
|
3415
|
+
# Atomic write: stream to a sibling tmp file then os.replace. A
|
|
3416
|
+
# concurrent reader (MCP prewarm, web `/api/health`, another CLI
|
|
3417
|
+
# invocation) that stats the destination mid-write would otherwise
|
|
3418
|
+
# observe a partial JSON file and raise `json.JSONDecodeError`.
|
|
3419
|
+
# os.replace is atomic on POSIX and on Windows >=3.3.
|
|
3420
|
+
output_path = (
|
|
3421
|
+
Path(output_path) if not isinstance(output_path, Path) else output_path
|
|
3422
|
+
)
|
|
3423
|
+
tmp_path = output_path.with_suffix(output_path.suffix + ".tmp")
|
|
3424
|
+
try:
|
|
3425
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
3426
|
+
json.dump(
|
|
3427
|
+
{
|
|
3428
|
+
"schema_version": CURRENT_SCHEMA,
|
|
3429
|
+
"nodes": self.nodes,
|
|
3430
|
+
"edges": self.edges,
|
|
3431
|
+
},
|
|
3432
|
+
f,
|
|
3433
|
+
indent=2,
|
|
3434
|
+
)
|
|
3435
|
+
os.replace(tmp_path, output_path)
|
|
3436
|
+
except Exception:
|
|
3437
|
+
# Leave the previous good graph in place if anything fails mid-write.
|
|
3438
|
+
try:
|
|
3439
|
+
tmp_path.unlink(missing_ok=True)
|
|
3440
|
+
except OSError:
|
|
3441
|
+
pass
|
|
3442
|
+
raise
|
|
3443
|
+
logger.info(
|
|
3444
|
+
"Graph exported to %s. Stats: %d nodes, %d edges",
|
|
3445
|
+
output_path,
|
|
3446
|
+
len(self.nodes),
|
|
3447
|
+
len(self.edges),
|
|
3448
|
+
)
|
|
3449
|
+
|
|
3450
|
+
|
|
3451
|
+
def main():
|
|
3452
|
+
"""CLI entry point for descry-generate."""
|
|
3453
|
+
import argparse
|
|
3454
|
+
|
|
3455
|
+
parser = argparse.ArgumentParser(description="Generate codebase knowledge graph")
|
|
3456
|
+
parser.add_argument(
|
|
3457
|
+
"path", nargs="?", default=".", help="Root path to index (default: .)"
|
|
3458
|
+
)
|
|
3459
|
+
parser.add_argument(
|
|
3460
|
+
"--no-scip",
|
|
3461
|
+
action="store_true",
|
|
3462
|
+
help="Disable SCIP resolution (use regex only)",
|
|
3463
|
+
)
|
|
3464
|
+
parser.add_argument(
|
|
3465
|
+
"--verbose", "-v", action="store_true", help="Enable verbose logging"
|
|
3466
|
+
)
|
|
3467
|
+
args = parser.parse_args()
|
|
3468
|
+
|
|
3469
|
+
if args.verbose:
|
|
3470
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
|
3471
|
+
|
|
3472
|
+
target = args.path
|
|
3473
|
+
|
|
3474
|
+
# Load config: auto-detect from target dir, apply .descry.toml, then
|
|
3475
|
+
# apply env vars (DESCRY_CACHE_DIR, DESCRY_NO_SCIP, DESCRY_NO_EMBEDDINGS,
|
|
3476
|
+
# etc.). Calling from_env() directly would use Path.cwd() which may
|
|
3477
|
+
# differ from the target directory, so we replicate its logic explicitly.
|
|
3478
|
+
from descry.handlers import DescryConfig, _env
|
|
3479
|
+
|
|
3480
|
+
# Handle SCIP opt-out CLI flag before from_env reads the env.
|
|
3481
|
+
if args.no_scip:
|
|
3482
|
+
os.environ["DESCRY_NO_SCIP"] = "1"
|
|
3483
|
+
|
|
3484
|
+
config = DescryConfig(project_root=Path(target).resolve())
|
|
3485
|
+
toml_data = DescryConfig._load_toml(config.project_root)
|
|
3486
|
+
config._apply_toml(toml_data)
|
|
3487
|
+
|
|
3488
|
+
# Apply env var overrides (mirrors DescryConfig.from_env).
|
|
3489
|
+
cache_dir_str = _env("DESCRY_CACHE_DIR")
|
|
3490
|
+
if cache_dir_str:
|
|
3491
|
+
config.cache_dir = Path(cache_dir_str)
|
|
3492
|
+
if _env("DESCRY_NO_SCIP").lower() in ("1", "true", "yes"):
|
|
3493
|
+
config.enable_scip = False
|
|
3494
|
+
if _env("DESCRY_NO_EMBEDDINGS").lower() in ("1", "true", "yes"):
|
|
3495
|
+
config.enable_embeddings = False
|
|
3496
|
+
|
|
3497
|
+
# B.3: always pass config.excluded_dirs so non-TOML projects still get
|
|
3498
|
+
# the full default exclusion set.
|
|
3499
|
+
config_excluded_dirs = config.excluded_dirs
|
|
3500
|
+
|
|
3501
|
+
# B.3: respect config.cache_dir instead of hardcoded ".descry_cache"
|
|
3502
|
+
cache_dir = config.cache_dir
|
|
3503
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
3504
|
+
|
|
3505
|
+
# Build the graph
|
|
3506
|
+
builder = CodeGraphBuilder(target, excluded_dirs=config_excluded_dirs)
|
|
3507
|
+
builder.process_directory()
|
|
3508
|
+
|
|
3509
|
+
# Generate SCIP indices if available and enabled
|
|
3510
|
+
scip_index = None
|
|
3511
|
+
if config.enable_scip and SCIP_SUPPORT_LOADED and scip_available():
|
|
3512
|
+
try:
|
|
3513
|
+
scip_status = get_scip_status()
|
|
3514
|
+
indexers = scip_status.get("indexers", {})
|
|
3515
|
+
enabled_indexers = [
|
|
3516
|
+
name for name, info in indexers.items() if info.get("available")
|
|
3517
|
+
]
|
|
3518
|
+
logger.info(f"SCIP: Enabled ({', '.join(enabled_indexers) or 'none'})")
|
|
3519
|
+
|
|
3520
|
+
cache_manager = ScipCacheManager(
|
|
3521
|
+
Path(target).resolve(),
|
|
3522
|
+
scip_extra_args=config.scip_extra_args,
|
|
3523
|
+
scip_skip_crates=config.scip_skip_crates,
|
|
3524
|
+
scip_toolchain=config.scip_rust_toolchain,
|
|
3525
|
+
scip_timeout_minutes=config.scip_timeout_minutes,
|
|
3526
|
+
)
|
|
3527
|
+
# Generate SCIP for all supported languages (Rust and TypeScript)
|
|
3528
|
+
scip_files = list(cache_manager.update_all().values())
|
|
3529
|
+
|
|
3530
|
+
if scip_files:
|
|
3531
|
+
scip_index = ScipIndex(scip_files)
|
|
3532
|
+
stats = scip_index.get_stats()
|
|
3533
|
+
logger.info(
|
|
3534
|
+
f"SCIP: Loaded {stats['definitions']} definitions, "
|
|
3535
|
+
f"{stats['unique_names']} unique names from {len(scip_files)} files"
|
|
3536
|
+
)
|
|
3537
|
+
except Exception as e:
|
|
3538
|
+
logger.warning(f"SCIP: Failed to load ({e}), using regex only")
|
|
3539
|
+
elif SCIP_SUPPORT_LOADED:
|
|
3540
|
+
status = get_scip_status()
|
|
3541
|
+
if status.get("disabled_by_env"):
|
|
3542
|
+
logger.info("SCIP: Disabled (DESCRY_NO_SCIP=1)")
|
|
3543
|
+
else:
|
|
3544
|
+
logger.info(
|
|
3545
|
+
"SCIP: Unavailable (no indexers found: install rust-analyzer and/or scip-typescript)"
|
|
3546
|
+
)
|
|
3547
|
+
|
|
3548
|
+
# Export with optional SCIP resolution
|
|
3549
|
+
graph_path = cache_dir / "codebase_graph.json"
|
|
3550
|
+
builder.export(str(graph_path), scip_index=scip_index)
|
|
3551
|
+
|
|
3552
|
+
# Generate embeddings for semantic search (if dependencies available and enabled)
|
|
3553
|
+
if config.enable_embeddings:
|
|
3554
|
+
try:
|
|
3555
|
+
from descry.embeddings import embeddings_available, SemanticSearcher
|
|
3556
|
+
|
|
3557
|
+
if embeddings_available():
|
|
3558
|
+
logger.info("Generating embeddings for semantic search...")
|
|
3559
|
+
# B.3: respect config.embedding_model
|
|
3560
|
+
searcher = SemanticSearcher(
|
|
3561
|
+
str(graph_path),
|
|
3562
|
+
force_rebuild=True,
|
|
3563
|
+
model_name=config.embedding_model,
|
|
3564
|
+
)
|
|
3565
|
+
logger.info(
|
|
3566
|
+
f"Embeddings generated: {len(searcher.nodes)} nodes indexed"
|
|
3567
|
+
)
|
|
3568
|
+
else:
|
|
3569
|
+
logger.debug(
|
|
3570
|
+
"Embeddings: sentence-transformers not available, skipping"
|
|
3571
|
+
)
|
|
3572
|
+
except ImportError:
|
|
3573
|
+
logger.debug("Embeddings: module not available, skipping")
|
|
3574
|
+
except Exception as e:
|
|
3575
|
+
logger.warning(f"Embeddings: Failed to generate ({e})")
|
|
3576
|
+
|
|
3577
|
+
|
|
3578
|
+
if __name__ == "__main__":
|
|
3579
|
+
main()
|