-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgraphql_typescript_analyzer.py
More file actions
1152 lines (1002 loc) · 57.4 KB
/
Copy pathgraphql_typescript_analyzer.py
File metadata and controls
1152 lines (1002 loc) · 57.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
GraphQL TypeScript Analyzer Extension
This extension analyzes TypeScript files for GraphQL usage patterns.
Similar to the JavaScript analyzer, it processes TypeScript content to extract
GraphQL definitions and Apollo hook calls.
LEVEL 1: gql definitions (TsGqlQuery/TsGqlMutation/TsGqlSubscription)
- Extracts gql`...` template literals
- Creates objects for query/mutation/subscription definitions
LEVEL 2: Apollo hook calls (GraphQL*Request)
- Extracts useQuery/useLazyQuery/useMutation/useSubscription
- Creates objects for hook usage
- Links to LEVEL 1 definitions
"""
import re
import os
from cast.analysers import ua, log, CustomObject, Bookmark, create_link
from cast import Event
# ─── Structured log helpers ────────────────────────────────────────────────────
# Format: [GraphQL][TS][<STAGE>][<ENTITY>][ctx=N] message
_ctx_seq = [0]
def _ctx():
_ctx_seq[0] += 1
return _ctx_seq[0]
def _glog(stage, entity, ctx, msg):
log.info('[GraphQL][TS][{}][{}][ctx={}] {}'.format(stage, entity, ctx, msg))
# ─── Resolver detection constants ─────────────────────────────────────────────
# Map operation type → TS KB type name for resolver objects
_TS_RESOLVER_TYPE_MAP = {
'Query': 'TsNodeJsResolverQuery',
'Mutation': 'TsNodeJsResolverMutation',
'Subscription': 'TsNodeJsResolverSubscription',
}
# Built-in GraphQL scalars and standard types — NOT custom field resolvers
_BUILTIN_GRAPHQL_NAMES = frozenset({
'Query', 'Mutation', 'Subscription',
'String', 'Int', 'Float', 'Boolean', 'ID',
'Date', 'DateTime', 'JSON', 'Upload',
})
# Special resolver keys to skip — includes middleware helpers that appear as function calls
# inside resolver bodies and get falsely matched by _RESOLVER_FIELD_RE's identifier( alternative.
_SKIP_FIELD_NAMES = frozenset({
'__resolveType', '__isTypeOf', 'subscribe',
# Apollo cache policy helpers (matched when files contain InMemoryCache type policies)
'merge', 'read', 'keyArgs',
# Common middleware / utility function calls that appear inside resolver bodies
'requireAuth', 'validateRole', 'validateMinimumRole',
'withFilter', 'publish', 'subscribe',
})
# Regex to extract service call from TS resolver body: ClassName.methodName(
# \b ensures we don't match a substring of a camelCase name (e.g. 'Service' inside 'documentService').
_TS_SERVICE_CALL_RE = re.compile(r'\b([A-Z]\w+)\.(\w+)\s*\(')
# Regex for lowercase instance-level service calls: instanceVar.methodName(
_TS_SERVICE_CALL_INSTANCE_RE = re.compile(r'([a-z]\w+)\.(\w+)\s*\(')
# Regexes for building import_map (ClassName → relative import path)
_IMPORT_NAMED_RE = re.compile(r"import\s+\{([^}]*)\}\s+from\s+['\"]([^'\"]+)['\"]")
_IMPORT_DEFAULT_RE = re.compile(r"import\s+([A-Z][A-Za-z0-9_]*)\s+from\s+['\"]([^'\"]+)['\"]")
# Regexes for building var_type_map (instanceVar → ClassName)
_VAR_NEW_RE = re.compile(r'(?:const|let|var)\s+([a-z]\w*)\s*=\s*new\s+([A-Z]\w*)\s*\(')
_VAR_TYPE_ANNOT_RE = re.compile(r'(?:const|let|var)\s+([a-z]\w*)\s*:\s*([A-Z]\w*)')
_CONSTRUCTOR_PARAM_RE = re.compile(
r'(?:private|protected|public|readonly)\s+([a-z]\w*)\s*:\s*([A-Z]\w*)'
)
# Regex to match a resolver section header like Query: { or Mutation: {
# Captures: (1) type name, (2) everything inside the outer braces
# This is used in the two-pass approach for resolver detection.
_RESOLVER_SECTION_RE = re.compile(
r'(?:^|\n)\s*(\w+)\s*:\s*\{',
re.MULTILINE
)
# Regex to match individual field resolvers inside a section.
# Matches: fieldName: ( or fieldName: async ( or fieldName: async function(
# or fieldName(parent (method shorthand)
_RESOLVER_FIELD_RE = re.compile(
r'(?:^|[,\n])\s*(\w+)\s*(?::\s*(?:async\s+)?(?:function\s*)?[\(]|[\(])',
re.MULTILINE
)
# Import from typescript_dependencies (local)
from typescript_dependencies.symbols import SourceFile
from typescript_dependencies.resolution import resolve_expressions
from typescript_dependencies.evaluation import EvaluationTool
from typescript_dependencies.ProgramSymbol import Program
# Import analysis tools from ts_parser
from ts_parser.analysis_results import ApolloAnalysisResults
from ts_parser.apollo_interpreter_ts import analyse_ts_fragment
from ts_parser.apollo_symbols import ApolloHookSymbol, GqlDefinitionSymbol
def analyse(source_file):
"""
Analyze TypeScript source file for Apollo Client hooks and GraphQL operations.
Based on test_ts.py analyse() function.
NOTE: For the real analyzer, the source_file is already parsed by the TypeScript analyzer.
We don't need to parse it again.
"""
# Create Apollo analysis results container
apollo_analysis_results = ApolloAnalysisResults()
apollo_analysis_results.ts_evaluation_tool = EvaluationTool()
# Analyze the source file for Apollo patterns
source_file.parsing_results = analyse_ts_fragment(source_file, apollo_analysis_results)
# Create links between hooks and their GQL definitions
apollo_analysis_results.create_links()
return apollo_analysis_results
class GraphQLTypeScriptAnalyzer(ua.Extension):
"""
Two-level GraphQL TypeScript Client analysis:
- LEVEL 1: gql definitions
- LEVEL 2: Apollo hook calls
"""
def __init__(self):
# Track definitions for linking (LEVEL 2 -> LEVEL 1)
self.gql_definitions = {} # Map operation_name -> KB object (e.g. "GetLambdaInvocations" -> CustomObject)
self.source_file_counter = 0
# Pending useLinks: hooks processed before their GQL definition file (Bug 2)
self.pending_links = [] # List of (request_obj, operation_name, caller_file_kb)
# Dedup cache for TsGqlUnresolvedDefinition objects (keyed by operation_name)
self.missing_gql_objects = {}
# Scope-keyed GQL resolution: (file_path, var_name, id(parent_symbol)) -> KB object.
# Enables correct same-file resolution via scope chain walk (nearest-scope-wins).
# When a hook calls useQuery(Q), we walk from the hook's scope upward until we find
# a GQL definition for "Q" — this correctly handles shadowing in nested scopes.
self.scoped_gql_defs = {}
# Import-aware cross-file resolution (Bug 9 fix).
# gql_defs_by_file_var: (source_file_path, var_name) -> op_name
# Allows matching useQuery(QUERY) to the exact GQL def exported by a specific file.
self.gql_defs_by_file_var = {}
# gql_obj_by_file_var: (source_file_path, var_name) -> KB object (CustomObject)
# Direct KB object lookup; bypasses gql_definitions[op_name] last-writer-wins
# collision when the same operation name is defined in multiple source files.
self.gql_obj_by_file_var = {}
# imported_var_to_file: (consumer_file_path, local_name) -> (source_file_path, original_name)
# Populated from import statements:
# import { QUERY } from './queries' → (consumer, 'QUERY') -> (source, 'QUERY')
# import { QUERY as Q } from './queries' → (consumer, 'Q') -> (source, 'QUERY')
# Storing original_name fixes alias resolution: gql_obj_by_file_var is keyed by
# the variable name in the source file, not the consumer's local alias.
self.imported_var_to_file = {}
# Map function_name -> KB object, populated from every processed TS file.
# Used to create callLink from codegen hook objects to the TS wrapper function.
self.ts_functions = {}
# Codegen hooks whose TS wrapper function was not yet seen when the hook was created.
# Each entry: (codegen_obj, function_name)
self.pending_codegen_links = []
# Set of (file_path, var_name) tuples for GQL defs that are exported.
# Used as a guard in import-aware cross-file resolution: non-exported defs
# must not be matched to hooks in other files.
self.exported_gql_fv_keys = set()
# Track created/failed objects for end-of-analysis summary
# Each entry: {'name': str, 'type': str, 'file_path': str}
self.created_objects = []
self.failed_objects = []
@Event('com.castsoftware.typescript', 'typescript_file')
def get_typescript_file(self, source_file):
"""
Event handler for TypeScript files.
This event is triggered for each TypeScript file analyzed.
We use this to process GraphQL-related code in TypeScript files.
"""
try:
self.source_file_counter += 1
# STEP 1: Run analysis (like in test_hooks_outline_01 and test_hooks_inline_02)
apollo_analysis_results = analyse(source_file)
# STEP 2: Get results for this specific file
file_path = source_file.get_path()
gql_defs = apollo_analysis_results.gql_definitions_by_file[file_path]
hooks = apollo_analysis_results.apollo_hooks_by_file[file_path]
# STEP 2b: Build import map for this file so cross-file GQL lookups are
# import-aware rather than relying on the global first-seen fallback (Bug 9).
self._build_import_map_for_file(source_file)
# STEP 3: Collect TS function / method KB objects for codegen callLink resolution.
# CAST has already created these KB objects before our event fires (CAST processes
# the file first, then fires 'typescript_file' for extensions).
# We do this BEFORE hook processing so same-file codegen callLinks can be resolved
# immediately without queuing (e.g. useGetXQuery defined and called in the same file).
try:
for sym in source_file.get_all_symbols():
sym_type = type(sym).__name__
if sym_type in ('Function', 'Method'):
fn_name = sym.get_name() if hasattr(sym, 'get_name') else None
if fn_name:
fn_kb = sym.get_kb_object() if hasattr(sym, 'get_kb_object') else None
if fn_kb:
if fn_name not in self.ts_functions:
self.ts_functions[fn_name] = fn_kb
except Exception as sym_ex:
log.warning('[GraphQL][TS] Error collecting function symbols: ' + str(sym_ex))
# STEP 4: LEVEL 1 - Save GQL definitions to KB
for gql_def in gql_defs:
try:
self._create_gql_definition(gql_def, source_file)
except Exception as save_ex:
log.warning('[GraphQL][TS] Error saving GQL definition: ' + str(save_ex))
# STEP 5: LEVEL 2 - Save Apollo hooks to KB
for hook in hooks:
try:
self._create_hook_object(hook, source_file, apollo_analysis_results)
except Exception as save_ex:
log.warning('[GraphQL][TS] Error saving Apollo hook: ' + str(save_ex))
# STEP 6: Detect Apollo Server resolver maps and create resolver KB objects
try:
self._extract_ts_resolvers(source_file)
except Exception as resolver_ex:
log.warning('[GraphQL][TS] Error extracting resolvers: ' + str(resolver_ex))
except Exception as e:
log.warning('[GraphQL][TS] Error processing file: ' + str(e))
def _build_import_map_for_file(self, source_file):
"""
Parse import statements of source_file and populate self.imported_var_to_file.
For each named import import { FOO } from './bar' (or import { FOO as F } from …)
we record: (consumer_file_path, local_name) -> resolved_source_file_path
This enables import-aware cross-file resolution: when useQuery(FOO) is found in
consumer.tsx, we look up which file exports FOO rather than using the global
first-seen fallback.
Only named imports are handled ({...} syntax). Default imports and star imports
are skipped — GQL constants are virtually always named exports in real codebases.
External packages (@apollo/client, etc.) fail get_module_from_import and are
silently skipped.
"""
try:
consumer_path = str(source_file.get_path())
for imp in source_file.get_imports():
try:
elements = imp.get_imported_elements()
if not elements:
continue
# Resolve the import path to an actual SourceFile object.
# get_module_from_import handles relative paths and extensions (.ts/.tsx).
# Raises / returns None for unresolvable modules (e.g. npm packages).
try:
imported_sf = source_file.get_module_from_import(imp)
except Exception:
imported_sf = None
if imported_sf is None:
# Try non-exact (fuzzy) path resolution as fallback
try:
imported_sf = source_file.get_module_from_import_with_non_exact_path(imp)
except Exception:
imported_sf = None
if imported_sf is None:
continue
source_path = str(imported_sf.get_path())
for elem in elements:
# local_name is the alias if present, else the original name.
# original_name is always the element name in the source file.
# e.g. import { GET_USERS as MY_QUERY } → local='MY_QUERY', original='GET_USERS'
# e.g. import { GET_USERS } → local='GET_USERS', original='GET_USERS'
local_name = None
original_name = None
try:
original_name = elem.get_element_name()
local_name = elem.get_alias_name() or original_name
except Exception as e:
log.warning('[GraphQL][TS] import element name lookup failed: ' + str(e))
if local_name and original_name:
key = (consumer_path, local_name)
if key not in self.imported_var_to_file:
self.imported_var_to_file[key] = (source_path, original_name)
except Exception:
pass
except Exception as ex:
log.warning('[GraphQL][TS] _build_import_map_for_file failed: ' + str(ex))
def _resolve_gql_for_hook(self, variable_name, hook_ps, fp):
"""
Resolve a hook's variable reference to the GQL definition KB object.
Strategy — scope chain walk, nearest-scope-wins:
1. Same-file: walk from the hook's scope upward through parent scopes.
First match wins (lexical scoping: inner scope shadows outer).
1.5. Same-file flat fallback: id()-based walk always fails with CAST Boost.Python
wrappers (each call creates a new wrapper object). String-keyed (fp, var_name)
lookup in gql_obj_by_file_var avoids the id() problem entirely.
2. Import-aware cross-file: the consumer file imports `variable_name`
from a known source file → look up that source file's GQL def.
Guard: the source def must be exported (non-exported defs are
file-private and must not be linked cross-file).
Returns 'PENDING' if source known but def not yet processed.
Returns:
- CustomObject: the resolved KB object
- 'PENDING': import source known but def not yet processed
- None: unresolvable
"""
# 1. Same-file scope chain walk
if fp and hook_ps is not None:
try:
visited = set()
scope = hook_ps
_diag_steps = []
while scope is not None:
sid = id(scope)
if sid in visited:
_diag_steps.append('CYCLE at id={}'.format(sid))
break
visited.add(sid)
key = (fp, variable_name, sid)
try:
scope_name = scope.get_name() if hasattr(scope, 'get_name') else '?'
except Exception:
scope_name = '?'
_diag_steps.append('scope_type={} scope_name={} scope_id={} key_match={}'.format(
type(scope).__name__, scope_name, sid, key in self.scoped_gql_defs))
if key in self.scoped_gql_defs:
method = 'same_scope' if scope is hook_ps else 'scope_chain'
obj = self.scoped_gql_defs[key]
try:
obj_name = obj.get_name() if hasattr(obj, 'get_name') else '?'
except Exception:
obj_name = '?'
_glog('RESOLVE', 'Hook', _ctx(),
'{} → {} via {}'.format(variable_name, obj_name, method))
return obj
try:
scope = getattr(scope, 'get_parent_symbol', lambda: None)()
except Exception as e:
log.warning('[GraphQL][TS] _resolve_gql_for_hook: get_parent_symbol failed '
'for var={} scope_type={}: {}'.format(
variable_name, type(scope).__name__, str(e)))
break
_glog('DIAG', 'ScopeWalk', _ctx(),
'MISS var={} hook_ps_type={} hook_ps_id={} steps=[{}]'.format(
variable_name,
type(hook_ps).__name__ if hook_ps else 'None',
id(hook_ps) if hook_ps else 'None',
' | '.join(_diag_steps)))
except Exception as e:
log.warning('[GraphQL][TS] _resolve_gql_for_hook: scope walk crashed '
'for var={}: {}'.format(variable_name, str(e)))
# 1.5. Same-file flat fallback.
# The scope chain walk above uses id() to compare CAST Boost.Python wrapper objects.
# Each call to get_all_symbols() / get_parent_symbol() creates a NEW Python wrapper
# for the same underlying C++ object, so id() comparisons always fail (849 MISSes
# observed in production logs). Fall back to a string-keyed lookup: (file_path, var_name).
# No export guard — same-file access is always valid regardless of export status.
if fp:
try:
obj = self.gql_obj_by_file_var.get((fp, variable_name))
if obj is not None:
try:
obj_name = obj.get_name() if hasattr(obj, 'get_name') else '?'
except Exception:
obj_name = '?'
_glog('RESOLVE', 'Hook', _ctx(),
'{} → {} via same-file flat fallback'.format(variable_name, obj_name))
return obj
_glog('DIAG', 'FlatFallback', _ctx(),
'MISS var={} not in gql_obj_by_file_var for fp={}'.format(variable_name, fp))
except Exception as e:
log.warning('[GraphQL][TS] _resolve_gql_for_hook: flat fallback crashed '
'for var={} fp={}: {}'.format(variable_name, fp, str(e)))
# 2. Import-aware cross-file lookup
if fp:
try:
import_entry = self.imported_var_to_file.get((fp, variable_name))
if import_entry is not None:
source_path, original_name = import_entry
obj = self.gql_obj_by_file_var.get((source_path, original_name))
if obj is not None:
fv_key = (source_path, original_name)
if fv_key not in self.exported_gql_fv_keys:
_glog('RESOLVE', 'Hook', _ctx(),
'{} → BLOCKED (not exported, file={})'.format(
variable_name, source_path))
return None
try:
obj_name = obj.get_name() if hasattr(obj, 'get_name') else '?'
except Exception:
obj_name = '?'
_glog('RESOLVE', 'Hook', _ctx(),
'{} → {} via import from {} (original={})'.format(
variable_name, obj_name, source_path, original_name))
return obj
_glog('DIAG', 'ImportAware', _ctx(),
'PENDING var={} source_path={} original={} (def not yet processed)'.format(
variable_name, source_path, original_name))
return 'PENDING'
_glog('DIAG', 'ImportAware', _ctx(),
'MISS var={} not in imported_var_to_file for fp={}'.format(variable_name, fp))
except Exception as e:
log.warning('[GraphQL][TS] _resolve_gql_for_hook: import-aware lookup crashed '
'for var={} fp={}: {}'.format(variable_name, fp, str(e)))
return None
def _create_gql_definition (self, gql_def, source_file):
"""
LEVEL 1: Create GraphQL client definition object (TsGqlQuery/TsGqlMutation/TsGqlSubscription).
Same logic as graphql_javascript_analyzer.py _create_gql_definition ()
Args:
gql_def: GqlDefinition object from analysis results
source_file: TypeScript SourceFile
"""
# Initialize tracking vars early so they're available in the except block
_track_name = '(unknown)'
_track_type = '(unknown)'
_track_file = str(source_file.get_path())
try:
# Step 1: Determine object type based on operation type
op_type = gql_def.operation_type
if op_type == 'query':
object_type = 'TsGqlQuery'
elif op_type == 'mutation':
object_type = 'TsGqlMutation'
elif op_type == 'subscription':
object_type = 'TsGqlSubscription'
else:
object_type = 'TsGqlQuery' # fallback
variable_name = gql_def.name
# Use the GQL operation name as the KB object name (e.g. "GetLambdaInvocations").
# This is globally unique (GQL spec), avoids CAST dedup collisions when the same
# variable name (e.g. "QUERY") is declared in multiple scopes of the same file,
# and makes schema matching trivial: operation_name == field in type Query/Mutation/Subscription.
# Fall back to variable_name only for anonymous gql templates (no parsed operation name).
kb_name = gql_def.operation_name or variable_name
_track_name = kb_name
_track_type = object_type
# Step 2: Build unique fullname (file:line format, same as JS analyzer)
file_path = str(source_file.get_path())
line_num = gql_def.raw_bookmark.ast.get_begin_line() if gql_def.raw_bookmark and gql_def.raw_bookmark.ast else 0
fullname = file_path + ':' + str(line_num)
# Step 3: Create CAST custom object
client_obj = CustomObject()
client_obj.set_type(object_type)
client_obj.set_name(kb_name)
client_obj.set_fullname(fullname)
# Step 4: Set parent (file-level KB object)
parent_kb = source_file.get_kb_object()
if parent_kb:
client_obj.set_parent(parent_kb)
# Step 5: Save object to KB (MUST be done before save_property)
client_obj.save()
# Step 6: Save properties (AFTER save())
client_obj.save_property('GraphQL_Client_Definition.operationName', gql_def.operation_name or '')
client_obj.save_property('GraphQL_Client_Definition.rawQueryText', gql_def.raw_query_text or '')
client_obj.save_property('GraphQL_Client_Definition.variables', gql_def.variables or '')
client_obj.save_property('GraphQL_Client_Definition.fieldsSelected', gql_def.fields_selected or '')
client_obj.save_property('GraphQL_Client_Definition.exported', 'true' if gql_def.exported else 'false')
# Step 7: Create bookmark for source navigation
try:
if gql_def.raw_bookmark:
bookmark = gql_def.raw_bookmark.get_bookmark()
if bookmark:
client_obj.save_position(bookmark)
except Exception:
pass
# Step 8: Store in caches for LEVEL 2 linking.
_c = _ctx()
self.gql_definitions[kb_name] = client_obj
# Scope-keyed map: stores KB object directly for same-file scope chain walk.
# First-seen wins per (file, var, scope) — prevents redeclaration collisions.
ps = getattr(gql_def, 'parent_symbol', None)
scoped_key = (file_path, variable_name, id(ps) if ps is not None else None)
_glog('DIAG', 'ScopedKey', _c,
'STORE var={} ps_type={} ps_name={} ps_id={} key={}'.format(
variable_name,
type(ps).__name__ if ps else 'None',
ps.get_name() if ps and hasattr(ps, 'get_name') else '?',
id(ps) if ps else 'None',
scoped_key))
if scoped_key not in self.scoped_gql_defs:
self.scoped_gql_defs[scoped_key] = client_obj
# File+var map for import-aware cross-file resolution.
fv_key = (file_path, variable_name)
if fv_key not in self.gql_defs_by_file_var:
self.gql_defs_by_file_var[fv_key] = kb_name
if fv_key not in self.gql_obj_by_file_var:
self.gql_obj_by_file_var[fv_key] = client_obj
# Track exported defs for cross-file resolution guard.
if gql_def.exported:
self.exported_gql_fv_keys.add(fv_key)
self.created_objects.append({'name': kb_name, 'type': object_type, 'file_path': file_path})
_glog('RESULT', 'Object', _c, '{} "{}" created'.format(object_type, kb_name))
except Exception as e:
log.warning('[GraphQL][TS] _create_gql_definition failed for "{}": {}'.format(_track_name, str(e)))
self.failed_objects.append({'name': _track_name, 'type': _track_type, 'file_path': _track_file})
# Map (source_pattern, hook_name) -> metamodel type name.
# All TS types use the Ts* prefix to distinguish from JS equivalents.
_HOOK_TYPE_MAP = {
('react_hook', 'useQuery'): 'TsGraphQLApolloHookQuery',
('react_hook', 'useLazyQuery'): 'TsGraphQLApolloHookLazyQuery',
('react_hook', 'useMutation'): 'TsGraphQLApolloHookMutation',
('react_hook', 'useSubscription'): 'TsGraphQLApolloHookSubscription',
('client_method', 'useQuery'): 'TsGraphQLApolloClientQuery',
('client_method', 'useMutation'): 'TsGraphQLApolloClientMutation',
('client_method', 'useSubscription'): 'TsGraphQLApolloClientSubscription',
('angular_method','useQuery'): 'TsGraphQLApolloAngularQuery',
('angular_method','useMutation'): 'TsGraphQLApolloAngularMutation',
('angular_method','useLazyQuery'): 'TsGraphQLApolloAngularWatchQuery',
('codegen_hook', 'useQuery'): 'TsGraphQLApolloCodegenQuery',
('codegen_hook', 'useMutation'): 'TsGraphQLApolloCodegenMutation',
('codegen_hook', 'useSubscription'): 'TsGraphQLApolloCodegenSubscription',
}
# Map (source_pattern, hook_name) -> visible name prefix used in set_name().
# React hooks keep the original useXxx prefix.
# Angular uses the call syntax (this.apollo.query/mutate/watchQuery).
# Apollo Client direct calls use client.query/mutate/subscribe.
_HOOK_NAME_PREFIX = {
('react_hook', 'useQuery'): 'useQuery',
('react_hook', 'useLazyQuery'): 'useLazyQuery',
('react_hook', 'useMutation'): 'useMutation',
('react_hook', 'useSubscription'): 'useSubscription',
('client_method', 'useQuery'): 'client.query',
('client_method', 'useMutation'): 'client.mutate',
('client_method', 'useSubscription'): 'client.subscribe',
('angular_method','useQuery'): 'apollo.query',
('angular_method','useMutation'): 'apollo.mutate',
('angular_method','useLazyQuery'): 'apollo.watchQuery',
('codegen_hook', 'useQuery'): 'useQuery',
('codegen_hook', 'useMutation'): 'useMutation',
('codegen_hook', 'useSubscription'): 'useSubscription',
}
def _create_hook_object(self, hook, source_file, apollo_analysis_results):
"""
LEVEL 2: Create GraphQL hook/call request object.
Dispatches to the correct metamodel type based on (source_pattern, hook_name):
react_hook → GraphQLApolloHook*
client_method → GraphQLApolloClient*
angular_method→ GraphQLAngular*
codegen_hook → GraphQLApolloCodegen*
Args:
hook: ApolloHookObject from analysis results
source_file: TypeScript SourceFile
apollo_analysis_results: ApolloAnalysisResults with gql_definitions
"""
# Initialize tracking vars early so they're available in the except block
_track_name = '(unknown)'
_track_type = '(unknown)'
_track_file = str(source_file.get_path())
try:
hook_name = hook.hook_name
operation_name = hook.operation_name
source_pattern = getattr(hook, 'source_pattern', 'react_hook')
_track_name = hook_name + ':' + operation_name
# Step 1: Determine object type from (source_pattern, hook_name)
object_type = self._HOOK_TYPE_MAP.get((source_pattern, hook_name))
if not object_type:
object_type = 'TsGraphQLApolloHookQuery'
_track_type = object_type
# Step 2: Get parent component (KB object).
# Priority:
# 1. hook.parent_symbol (stored by the interpreter via find_parent_symbol_for_ast_node)
# — gives the innermost Function/Method that contains the hook call.
# This makes callLink(function → hook_obj) correct even for hooks inside
# nested functions (e.g. useLazyQuery inside a codegen wrapper function).
# 2. get_first_kb_parent() on the raw AST node (only works for CAST AST nodes,
# not for typescript_dependencies nodes — kept as a future-proof fallback).
# 3. The file itself as last resort.
parent_kb = None
if hook.parent_symbol and hasattr(hook.parent_symbol, 'get_kb_object'):
try:
parent_kb = hook.parent_symbol.get_kb_object()
except Exception:
parent_kb = None
if not parent_kb and hook.raw_bookmark and hook.raw_bookmark.ast:
if hasattr(hook.raw_bookmark.ast, 'get_first_kb_parent'):
first_kb_parent = hook.raw_bookmark.ast.get_first_kb_parent()
if first_kb_parent:
parent_kb = first_kb_parent.get_kb_object()
# Fallback: use the file as parent
if not parent_kb:
parent_kb = source_file.get_kb_object()
if not parent_kb:
return
# Step 3: Build unique fullname (file:line format, same as JS analyzer)
file_path = str(source_file.get_path())
line_num = hook.raw_bookmark.ast.get_begin_line() if hook.raw_bookmark and hook.raw_bookmark.ast else 0
fullname = file_path + ':' + str(line_num)
# Step 4: Build unique name.
# React hooks: useQuery:GetUsers — Angular: apollo.query:GetUsers — Client: client.query:GetUsers
# Codegen hooks: the full hook function name IS the name (e.g. "useGetPortfolioAllocationLazyQuery")
if source_pattern == 'codegen_hook':
unique_request_name = operation_name
else:
name_prefix = self._HOOK_NAME_PREFIX.get((source_pattern, hook_name), hook_name)
unique_request_name = name_prefix + ':' + operation_name
# Step 5: Create CAST custom object
request_obj = CustomObject()
request_obj.set_type(object_type)
request_obj.set_name(unique_request_name)
request_obj.set_fullname(fullname)
request_obj.set_parent(parent_kb)
# Step 6: Save object to KB (MUST be done before save_property)
request_obj.save()
_c = _ctx()
_glog('RESULT', 'Object', _c, '{} "{}" created'.format(object_type, unique_request_name))
# Step 7: Save properties (AFTER save()).
# Only GraphQLApolloHook* types inherit GraphQL_Hook_Request (which defines hookType).
# The new Pattern 1/2/3 types have no properties — skip save_property for them.
if source_pattern == 'react_hook':
request_obj.save_property('GraphQL_Hook_Request.hookType', hook_name)
# Step 8: Create bookmark and CALL link (component -> request)
hook_bookmark = None
try:
if hook.raw_bookmark:
hook_bookmark = hook.raw_bookmark.get_bookmark()
if hook_bookmark:
request_obj.save_position(hook_bookmark)
try:
create_link("callLink", parent_kb, request_obj, hook_bookmark)
except Exception as link_ex:
log.warning('[GraphQL][TS] callLink failed for "{}": {}'.format(unique_request_name, str(link_ex)))
except Exception as e:
log.warning('[GraphQL][TS] bookmark/callLink failed for "{}": {}'.format(unique_request_name, str(e)))
# Step 9: Create useLink (request -> GQL definition).
# Codegen hooks do NOT get a useLink here — their chain is:
# codegen call site → CALL → codegen wrapper function → CALL → standard hook → useLink → GQL def
# The useLink is created by the standard hook object inside the wrapper, not here.
if source_pattern != 'codegen_hook':
hook_ps = getattr(hook, 'parent_symbol', None)
fp = str(source_file.get_path())
resolved_obj = self._resolve_gql_for_hook(operation_name, hook_ps, fp)
if resolved_obj == 'PENDING' or resolved_obj is None:
self.pending_links.append(
(request_obj, operation_name, source_file.get_kb_object(), source_pattern, hook_ps, fp, hook_bookmark))
if resolved_obj is None:
_glog('RESOLVE', 'Hook', _c, '{} → UNRESOLVED (pending)'.format(operation_name))
else:
try:
create_link("useLink", request_obj, resolved_obj)
_glog('RESULT', 'Link', _c, 'useLink {} → {}'.format(unique_request_name, operation_name))
except Exception as link_ex:
log.warning('[GraphQL][TS] useLink failed for "{}": {}'.format(unique_request_name, str(link_ex)))
else:
# Codegen hook: create callLink to the TS wrapper function so the chain
# codegen_obj → CALL → wrapper_function → CALL → useLazyQuery → USE → GqlDef is visible.
if operation_name in self.ts_functions:
fn_kb = self.ts_functions[operation_name]
try:
create_link("callLink", request_obj, fn_kb)
except Exception as link_ex:
log.warning('[GraphQL][TS] codegen callLink failed for "{}": {}'.format(operation_name, str(link_ex)))
else:
self.pending_codegen_links.append((request_obj, operation_name))
self.created_objects.append({'name': unique_request_name, 'type': object_type, 'file_path': file_path})
except Exception as e:
log.warning('[GraphQL][TS] _create_hook_object failed for "{}": {}'.format(_track_name, str(e)))
self.failed_objects.append({'name': _track_name, 'type': _track_type, 'file_path': _track_file})
# ──────────────────────────────────── Resolver detection (Phase 3) ────────
def _extract_ts_resolvers(self, source_file):
"""
Detect Apollo Server resolver maps in a TypeScript file.
Uses regex on raw source text to find resolver sections:
{ Query: { getUsers: ... }, Mutation: { createUser: ... }, User: { posts: ... } }
Creates TsNodeJsResolver{Query,Mutation,Subscription,Custom} KB objects.
"""
try:
file_path = str(source_file.get_path())
try:
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
source_text = f.read()
except Exception:
return
if not source_text:
return
# Quick guard: file must contain at least one standard resolver section keyword
has_query = 'Query' in source_text
has_mutation = 'Mutation' in source_text
has_subscription = 'Subscription' in source_text
if not (has_query or has_mutation or has_subscription):
return
# Find all resolver section headers and extract their fields.
# Strategy: for each TypeName: { match, find the balanced closing brace,
# then extract field names from the content between the braces.
found = [] # list of (op_type, field_name, line_number)
has_standard_type = False
for section_match in _RESOLVER_SECTION_RE.finditer(source_text):
type_name = section_match.group(1)
brace_start = section_match.end() - 1 # position of the '{'
# Find balanced closing brace
brace_content = self._extract_brace_content(source_text, brace_start)
if brace_content is None:
continue
is_standard = type_name in ('Query', 'Mutation', 'Subscription')
if is_standard:
has_standard_type = True
# Extract field names from the section content
for field_match in _RESOLVER_FIELD_RE.finditer(brace_content):
field_name = field_match.group(1)
if (field_name in _SKIP_FIELD_NAMES
or field_name in ('async', 'function', 'return', 'const',
'let', 'var', 'if', 'else', 'try', 'catch')):
continue
# Compute begin_line: use group(1) start so we land on the
# field name line, not on the preceding comma/newline.
abs_pos = brace_start + 1 + field_match.start(1)
line_num = source_text[:abs_pos].count('\n') + 1
# Extract the field's function body for service call extraction.
# Use brace-matching to capture the full body; fall back to a
# 500-char slice for arrow functions without braces (e.g. => expr).
field_body_start = brace_start + 1 + field_match.end()
field_body = source_text[field_body_start:field_body_start + 500] # fallback
# Compute end_line and replace the fallback with the full body
# when brace-matching succeeds — reuses the same traversal.
end_line = line_num # fallback: same line
func_text = source_text[field_body_start:]
open_brace = func_text.find('{')
if open_brace != -1:
body_content = self._extract_brace_content(func_text, open_brace)
if body_content is not None:
close_pos = field_body_start + open_brace + 1 + len(body_content)
end_line = source_text[:close_pos].count('\n') + 1
field_body = body_content # full body replaces 500-char fallback
if is_standard:
found.append((type_name, field_name, line_num, end_line, field_body))
else:
# Store as potential custom resolver; will only create if
# file also has at least one standard type
found.append((type_name, field_name, line_num, end_line, field_body))
if not found or not has_standard_type:
return
# Dedup and create KB objects
parent_kb = source_file.get_kb_object()
if not parent_kb:
return
seen = set()
for op_type, field_name, line_num, end_line, field_body in found:
pair = (op_type, field_name)
if pair in seen:
continue
# Custom types only created if file has standard types (guard against false positives)
if op_type not in ('Query', 'Mutation', 'Subscription'):
if op_type in _BUILTIN_GRAPHQL_NAMES:
continue
if not op_type[0].isupper():
continue
seen.add(pair)
self._create_ts_resolver(op_type, field_name, line_num, end_line,
field_body, parent_kb, file_path, source_file,
source_text=source_text)
except Exception as e:
log.warning('[GraphQL][TS] _extract_ts_resolvers failed: ' + str(e))
def _extract_brace_content(self, text, brace_start):
"""
Extract content between balanced braces starting at brace_start.
Returns the content string (excluding outer braces), or None if unbalanced.
"""
depth = 0
i = brace_start
while i < len(text):
ch = text[i]
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
return text[brace_start + 1:i]
elif ch in ('"', "'", '`'):
# Skip string literals to avoid false brace matches
quote = ch
i += 1
while i < len(text) and text[i] != quote:
if text[i] == '\\':
i += 1 # skip escaped char
i += 1
i += 1
return None
def _extract_ts_service_call(self, field_body, source_text=None, file_path=None):
"""
Extract (serviceClass, serviceMethod, serviceFilePath) from a TS resolver body.
Detection order:
1. Uppercase class call: ClassName.methodName( (e.g. UserService.findById()
2. Lowercase instance call: instanceVar.method( resolved via var_type_map
Returns (className, methodName, resolvedFilePath) or (None, None, None).
"""
if not field_body:
return (None, None, None)
# Build import_map { ClassName → relative_path } and
# var_type_map { instanceVar → ClassName } from full source text
import_map = {}
var_type_map = {}
if source_text:
# Named imports: import { Foo, Bar as Baz } from '...'
for nm in _IMPORT_NAMED_RE.finditer(source_text):
names_str, rel_path = nm.group(1), nm.group(2)
for name_part in names_str.split(','):
name_part = name_part.strip()
if ' as ' in name_part:
_, local = name_part.split(' as ', 1)
local = local.strip()
else:
local = name_part
if local and local[0].isupper():
import_map[local] = rel_path
# Default imports: import Foo from '...'
for nm in _IMPORT_DEFAULT_RE.finditer(source_text):
import_map[nm.group(1)] = nm.group(2)
# Instance types: const foo = new Foo()
for nm in _VAR_NEW_RE.finditer(source_text):
var_type_map[nm.group(1)] = nm.group(2)
# Instance types: const foo: Foo = ...
for nm in _VAR_TYPE_ANNOT_RE.finditer(source_text):
if nm.group(1) not in var_type_map:
var_type_map[nm.group(1)] = nm.group(2)
# Constructor params: private|protected|public foo: Foo
for nm in _CONSTRUCTOR_PARAM_RE.finditer(source_text):
if nm.group(1) not in var_type_map:
var_type_map[nm.group(1)] = nm.group(2)
_CLASS_FP = frozenset({
'Promise', 'console', 'Object', 'Array', 'JSON', 'Math',
'Error', 'Date', 'String', 'Number', 'Boolean', 'RegExp',
})
_INSTANCE_FP = frozenset({
'this', 'ctx', 'context', 'req', 'res',
'console', 'process', 'module',
})
class_name = method_name = None
# 1. Try uppercase class call: ClassName.method(
# Only accept if the class name is in import_map — avoids false-positive matches
# (e.g. a bare "Service" token from unrelated code) blocking the instance-call
# resolution in Step 2.
m = _TS_SERVICE_CALL_RE.search(field_body)
if m and m.group(1) not in _CLASS_FP:
if m.group(1) in import_map:
class_name = m.group(1)
method_name = m.group(2)
else:
log.info('[GraphQL][TS] _extract_ts_service_call: Step1 skipped class "{}" '
'(not in import_map) — match context: {!r}'.format(
m.group(1), field_body[max(0, m.start()-20):m.end()+20]))
# 2. Try lowercase instance call: instanceVar.method(
# Use finditer() so that false-positive first matches (console.log, args.x, etc.)
# don't block us from finding the real service call further in the body.
if not class_name:
for m in _TS_SERVICE_CALL_INSTANCE_RE.finditer(field_body):
inst = m.group(1)
if inst not in _INSTANCE_FP and inst in var_type_map:
class_name = var_type_map[inst]
method_name = m.group(2)
break
if not class_name or not method_name:
log.info('[GraphQL][TS] _extract_ts_service_call: no match. '
'import_map keys={} var_type_map keys={} field_body[:120]={!r}'.format(
list(import_map.keys()), list(var_type_map.keys()),
field_body[:120] if field_body else ''))
return (None, None, None)
# Resolve the service file path from import_map
service_file_path = None
if file_path and class_name in import_map:
resolver_dir = os.path.dirname(file_path)
rel = import_map[class_name]
for ext in ('', '.ts', '.tsx', '/index.ts'):
candidate = os.path.normpath(os.path.join(resolver_dir, rel + ext))
if os.path.exists(candidate):
service_file_path = candidate
break
if service_file_path is None:
service_file_path = os.path.normpath(
os.path.join(resolver_dir, rel + '.ts'))
else:
log.info('[GraphQL][TS] _extract_ts_service_call: class "{}" found but not in import_map. '
'file_path truthy={} import_map keys={}'.format(
class_name, bool(file_path), list(import_map.keys())))
return (class_name, method_name, service_file_path)
def _create_ts_resolver(self, op_type, field_name, line_num, end_line, field_body,
parent_kb, file_path, source_file=None, source_text=None):
"""Create a TsNodeJsResolver* KB object for one TS resolver function."""
try:
resolver_type = _TS_RESOLVER_TYPE_MAP.get(op_type, 'TsNodeJsResolverCustom')
fullname = file_path + ':' + str(line_num)
obj = CustomObject()
obj.set_name(field_name)
obj.set_type(resolver_type)
obj.set_fullname(fullname)
obj.set_parent(parent_kb)
obj.save()
try:
if source_file is not None and line_num:
try:
file_obj = source_file.get_file()
except Exception:
file_obj = source_file
obj.save_position(Bookmark(file_obj, line_num, 1, end_line or line_num, 1))