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/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()