forked from colbymchenry/codegraph
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcallback-synthesizer.ts
More file actions
1223 lines (1161 loc) · 55.1 KB
/
Copy pathcallback-synthesizer.ts
File metadata and controls
1223 lines (1161 loc) · 55.1 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
/**
* Callback / observer edge synthesis — Phase 1 + 2.
*
* Closes dynamic-dispatch holes where a dispatcher invokes callbacks registered
* elsewhere. Two channel shapes:
*
* (1) Field-backed observer (Phase 1):
* onUpdate(cb) { this.callbacks.add(cb); } // registrar
* triggerUpdate() { for (cb of this.callbacks) cb(); } // dispatcher
* scene.onUpdate(this.triggerRender) // registration
* → synthesize triggerUpdate → triggerRender
*
* (2) String-keyed EventEmitter (Phase 2):
* this.on('mount', function onmount(){...}) // registration
* fn.emit('mount', this) // dispatch
* → synthesize (method containing emit('mount')) → onmount
*
* Whole-graph pass after base resolution. High-precision/low-recall by design:
* named callbacks only; field channels paired by file+field; EventEmitter
* channels capped by event fan-out (generic names like 'error' skipped — they
* need receiver-type matching, deferred to Phase 3). All synthesized edges are
* tagged `provenance:'heuristic'`. See docs/design/callback-edge-synthesis.md.
*/
import type { Edge, Node } from '../types';
import type { QueryBuilder } from '../db/queries';
import type { ResolutionContext } from './types';
import { isGeneratedFile } from '../extraction/generated-detection';
import { stripCommentsForRegex } from './strip-comments';
const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
const MAX_CALLBACKS_PER_CHANNEL = 40;
const EVENT_FANOUT_CAP = 6; // skip events with more handlers/dispatchers than this (too generic without type info)
const ON_RE = /\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*(?:function\s+(\w+)|(?:this\.)?(\w+))/g;
const EMIT_RE = /\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]/g;
const SETSTATE_RE = /this\.setState\s*\(/;
const FLUTTER_SETSTATE_RE = /\bsetState\s*\(/; // Flutter: setState((){…}) / this.setState
const JSX_TAG_RE = /<([A-Z][A-Za-z0-9_]*)[\s/>]/g;
const MAX_JSX_CHILDREN = 30;
// Vue SFC templates: kebab-case child components (<el-button> → ElButton) and
// event bindings (@click="fn" / v-on:click="fn"). PascalCase children (<VPNav/>)
// are already caught by JSX_TAG_RE via the SFC component node.
const VUE_KEBAB_RE = /<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)[\s/>]/g;
const VUE_HANDLER_RE = /(?:@|v-on:)([a-zA-Z][\w-]*)(?:\.[\w]+)*\s*=\s*"([^"]+)"/g;
// Composable/hook destructure: `const { close: closeSidebar } = useSidebarControl()`.
// Captures the destructure body + the called composable; only `use*` calls qualify.
const VUE_DESTRUCTURE_RE = /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*(\w+)\s*\(/g;
// Closure-collection dynamic dispatch (language-agnostic, Swift-first). A method
// appends a closure to a collection property; another method iterates that
// property *invoking each element* (`coll.forEach { $0() }` / `{ it() }`). The
// element-invoke (`$0(` / `it(`) PROVES the collection holds closures, so pairing
// a dispatcher to same-named registrars (`.append`/`.add`/`.push`/`.insert`,
// incl. Swift `prop.write { $0.append }`) is high-precision. Cross-file/class by
// design: Alamofire appends in `DataRequest.validate` but iterates in the base
// `Request.didCompleteTask` — neither same-file nor same-class pairing reaches it.
const CC_DISPATCH_RE = /(\w+)\.forEach\s*\{\s*(?:\$0|it)\s*\(/g;
const CC_APPEND_WRITE_RE = /(\w+)\.write\s*\{\s*\$0(?:\.(\w+))?\.(?:append|add|push|insert)\s*\(/g;
const CC_APPEND_DIRECT_RE = /(\w+)\.(?:append|add|push|insert)\s*\(/g;
const CC_FANOUT_CAP = 8; // skip a field name with more dispatchers/registrars than this (too generic to pair confidently)
function kebabToPascal(s: string): string {
return s.split('-').map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('');
}
function sliceLines(content: string, startLine?: number, endLine?: number): string | null {
if (!startLine || !endLine) return null;
return content.split('\n').slice(startLine - 1, endLine).join('\n');
}
function registrarField(src: string): string | null {
const m = src.match(/this\.(\w+)\.(?:add|push|set)\(/);
return m ? m[1]! : null;
}
function dispatcherField(src: string): string | null {
const forOf = src.match(/\bof\s+(?:Array\.from\(\s*)?this\.(\w+)/);
if (forOf && /\b\w+\s*\(/.test(src)) return forOf[1]!;
const forEach = src.match(/this\.(\w+)\.forEach\(/);
if (forEach) return forEach[1]!;
return null;
}
const FN_KINDS = new Set(['method', 'function', 'component']);
/** Innermost function/method node whose line range contains `line`. */
function enclosingFn(nodesInFile: Node[], line: number): Node | null {
let best: Node | null = null;
for (const n of nodesInFile) {
if (!FN_KINDS.has(n.kind)) continue;
const end = n.endLine ?? n.startLine;
if (n.startLine <= line && end >= line) {
if (!best || n.startLine >= best.startLine) best = n; // prefer the tightest (latest-starting) encloser
}
}
return best;
}
/** Phase 1: field-backed observer channels (registrar/dispatcher share a store). */
function fieldChannelEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
const candidates = [...queries.getNodesByKind('method'), ...queries.getNodesByKind('function')];
const registrars: Array<{ node: Node; field: string }> = [];
const dispatchers: Array<{ node: Node; field: string }> = [];
for (const m of candidates) {
const isReg = REGISTRAR_NAME.test(m.name);
const isDisp = DISPATCHER_NAME.test(m.name);
if (!isReg && !isDisp) continue;
const content = ctx.readFile(m.filePath);
const src = content && sliceLines(content, m.startLine, m.endLine);
if (!src) continue;
if (isReg) { const f = registrarField(src); if (f) registrars.push({ node: m, field: f }); }
if (isDisp) { const f = dispatcherField(src); if (f) dispatchers.push({ node: m, field: f }); }
}
const edges: Edge[] = [];
const seen = new Set<string>();
for (const reg of registrars) {
const chDispatchers = dispatchers.filter(
(d) => d.node.filePath === reg.node.filePath && d.field === reg.field
);
if (chDispatchers.length === 0) continue;
const argRe = new RegExp(`${reg.node.name}\\s*\\(\\s*(?:this\\.)?(\\w+)`);
let added = 0;
for (const e of queries.getIncomingEdges(reg.node.id, ['calls'])) {
if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
if (!e.line) continue;
const caller = queries.getNodeById(e.source);
if (!caller) continue;
const line = ctx.readFile(caller.filePath)?.split('\n')[e.line - 1];
const am = line?.match(argRe);
if (!am) continue;
const fn = ctx.getNodesByName(am[1]!).find((n) => n.kind === 'method' || n.kind === 'function');
if (!fn) continue;
for (const disp of chDispatchers) {
if (disp.node.id === fn.id) continue;
const key = `${disp.node.id}>${fn.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: disp.node.id, target: fn.id, kind: 'calls', line: disp.node.startLine,
provenance: 'heuristic',
metadata: {
synthesizedBy: 'callback', via: reg.node.name, field: reg.field,
// Where the callback was wired up (`scene.onUpdate(this.triggerRender)`).
// This is the #1 thing an agent reads/greps to explain the flow — surface
// it so node/trace/context can show it without a callers() + Read round-trip.
registeredAt: `${caller.filePath}:${e.line}`,
},
});
added++;
}
}
}
return edges;
}
/**
* Closure-collection dispatch: dispatcher iterates a closure-collection property
* invoking each element; registrar appends a closure to the same-named property.
* Emits dispatcher → registrar so a flow reaches the registration site (where the
* appended closure's body — and its callers — live). High-precision: the
* dispatcher's element-invoke is the gate (a `.forEach` that does NOT invoke its
* element is ignored), so a repo with no closure-collection dispatch yields zero
* edges regardless of how many `.append`/`.push` sites it has.
*
* Pairs globally by field name (cross-file/class is required — see Alamofire's
* base-class `Request.didCompleteTask` iterating `validators` appended by the
* subclass `DataRequest.validate`), bounded by a fan-out cap so a generic field
* name shared across unrelated classes can't fan out into noise.
*/
function closureCollectionEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
const candidates = [...queries.getNodesByKind('method'), ...queries.getNodesByKind('function')];
const dispatchers = new Map<string, Array<{ node: Node; line: number }>>(); // field → dispatcher methods + forEach line
const registrars = new Map<string, Array<{ node: Node; line: number }>>(); // field → registrar methods + append line
const addReg = (field: string | undefined, node: Node, absLine: number) => {
if (!field || /^\d+$/.test(field)) return; // `$0.append` mis-captures the `0`; the write-RE owns that field
const arr = registrars.get(field) ?? [];
if (!arr.some((r) => r.node.id === node.id)) arr.push({ node, line: absLine });
registrars.set(field, arr);
};
for (const m of candidates) {
const content = ctx.readFile(m.filePath);
const src = content && sliceLines(content, m.startLine, m.endLine);
if (!src) continue;
const hasForEach = src.includes('.forEach');
const hasAppend = src.includes('.append(') || src.includes('.add(') || src.includes('.push(') || src.includes('.insert(');
if (!hasForEach && !hasAppend) continue;
const lineAt = (idx: number) => (m.startLine ?? 1) + src.slice(0, idx).split('\n').length - 1;
if (hasForEach) {
CC_DISPATCH_RE.lastIndex = 0;
let d: RegExpExecArray | null;
while ((d = CC_DISPATCH_RE.exec(src))) {
const arr = dispatchers.get(d[1]!) ?? [];
if (!arr.some((n) => n.node.id === m.id)) arr.push({ node: m, line: lineAt(d.index) });
dispatchers.set(d[1]!, arr);
}
}
if (hasAppend) {
CC_APPEND_WRITE_RE.lastIndex = 0;
let w: RegExpExecArray | null;
while ((w = CC_APPEND_WRITE_RE.exec(src))) addReg(w[2] || w[1], m, lineAt(w.index)); // nested `$0.streams` else the `.write` receiver
CC_APPEND_DIRECT_RE.lastIndex = 0;
let a: RegExpExecArray | null;
while ((a = CC_APPEND_DIRECT_RE.exec(src))) addReg(a[1], m, lineAt(a.index));
}
}
const edges: Edge[] = [];
const seen = new Set<string>();
for (const [field, disps] of dispatchers) {
const regs = registrars.get(field);
if (!regs || regs.length === 0) continue;
if (disps.length > CC_FANOUT_CAP || regs.length > CC_FANOUT_CAP) continue; // generic field — can't pair confidently
for (const disp of disps) for (const reg of regs) {
if (disp.node.id === reg.node.id) continue;
const key = `${disp.node.id}>${reg.node.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: disp.node.id, target: reg.node.id, kind: 'calls', line: disp.line,
provenance: 'heuristic',
metadata: { synthesizedBy: 'closure-collection', field, registeredAt: `${reg.node.filePath}:${reg.line}` },
});
}
}
return edges;
}
/** Phase 2: string-keyed EventEmitter channels (on('e', fn) ↔ emit('e')). */
function eventEmitterEdges(ctx: ResolutionContext): Edge[] {
const emitsByEvent = new Map<string, Set<string>>(); // event → dispatcher node ids
const handlersByEvent = new Map<string, Map<string, string>>(); // event → handler id → registration site (file:line)
for (const file of ctx.getAllFiles()) {
const content = ctx.readFile(file);
if (!content) continue;
const hasEmit = content.includes('.emit(') || content.includes('.fire(') || content.includes('.dispatchEvent(');
const hasOn = content.includes('.on(') || content.includes('.once(') || content.includes('.addListener(');
if (!hasEmit && !hasOn) continue;
const nodesInFile = ctx.getNodesInFile(file);
const lineOf = (idx: number) => content.slice(0, idx).split('\n').length;
if (hasEmit) {
EMIT_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = EMIT_RE.exec(content))) {
const disp = enclosingFn(nodesInFile, lineOf(m.index));
if (!disp) continue;
const set = emitsByEvent.get(m[1]!) ?? new Set<string>();
set.add(disp.id); emitsByEvent.set(m[1]!, set);
}
}
if (hasOn) {
ON_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ON_RE.exec(content))) {
const handlerName = m[2] || m[3];
if (!handlerName) continue;
const handler = ctx.getNodesByName(handlerName).find((n) => n.kind === 'function' || n.kind === 'method');
if (!handler) continue;
const map = handlersByEvent.get(m[1]!) ?? new Map<string, string>();
map.set(handler.id, `${file}:${lineOf(m.index)}`); handlersByEvent.set(m[1]!, map);
}
}
}
const edges: Edge[] = [];
const seen = new Set<string>();
for (const [event, dispatchers] of emitsByEvent) {
const handlers = handlersByEvent.get(event);
if (!handlers) continue;
// Precision guard: a generic event name with many handlers/dispatchers can't
// be matched without receiver-type info (Phase 3) — skip rather than over-link.
if (dispatchers.size > EVENT_FANOUT_CAP || handlers.size > EVENT_FANOUT_CAP) continue;
for (const d of dispatchers) for (const [h, registeredAt] of handlers) {
if (d === h) continue;
const key = `${d}>${h}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({ source: d, target: h, kind: 'calls', provenance: 'heuristic', metadata: { synthesizedBy: 'event-emitter', event, registeredAt } });
}
}
return edges;
}
/**
* Phase 4: React class-component re-render. `this.setState(...)` re-runs the
* component's `render()`, but that hop is React-internal — no static edge — so a
* flow like "mutation → setState → canvas repaint" dead-ends at setState even
* though `render → getRenderableElements → …` is fully call-connected after it.
* Bridge it: for each class that has a `render` method, link every sibling method
* whose body calls `this.setState(` → `render`. The setState gate keeps this to
* React class components (a non-React class with a `render` method won't call
* `this.setState`). Over-approximation (all setState methods reach render) is
* accepted — it's reachability-correct, like the callback channels.
*/
function reactRenderEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
for (const cls of queries.getNodesByKind('class')) {
const children = queries.getOutgoingEdges(cls.id, ['contains'])
.map((e) => queries.getNodeById(e.target))
.filter((n): n is Node => !!n && n.kind === 'method');
const render = children.find((n) => n.name === 'render');
if (!render) continue;
let added = 0;
for (const m of children) {
if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
if (m.id === render.id) continue;
const content = ctx.readFile(m.filePath);
const src = content && sliceLines(content, m.startLine, m.endLine);
if (!src || !SETSTATE_RE.test(src)) continue;
const key = `${m.id}>${render.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: m.id, target: render.id, kind: 'calls', line: m.startLine,
provenance: 'heuristic',
metadata: { synthesizedBy: 'react-render', via: 'setState', registeredAt: `${render.filePath}:${render.startLine}` },
});
added++;
}
}
return edges;
}
/**
* Phase 4b: Flutter setState → build (the Dart analog of react-render). In a
* StatefulWidget's State class, `setState(() {…})` re-runs `build(context)`, but
* that hop is framework-internal (Flutter calls build), so a flow like
* "onPressed → _increment → setState → rebuilt UI" dead-ends at setState. Bridge
* it: for each Dart class with a `build` method, link every sibling method whose
* body calls `setState(` → `build`. The setState gate + `.dart` file keep this to
* Flutter State classes. Over-approximation accepted (reachability-correct).
*/
function flutterBuildEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
for (const cls of queries.getNodesByKind('class')) {
const children = queries.getOutgoingEdges(cls.id, ['contains'])
.map((e) => queries.getNodeById(e.target))
.filter((n): n is Node => !!n && n.kind === 'method');
const build = children.find((n) => n.name === 'build');
if (!build || !build.filePath.endsWith('.dart')) continue;
let added = 0;
for (const m of children) {
if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
if (m.id === build.id) continue;
const content = ctx.readFile(m.filePath);
const src = content && sliceLines(content, m.startLine, m.endLine);
if (!src || !FLUTTER_SETSTATE_RE.test(src)) continue;
const key = `${m.id}>${build.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: m.id, target: build.id, kind: 'calls', line: m.startLine,
provenance: 'heuristic',
metadata: { synthesizedBy: 'flutter-build', via: 'setState', registeredAt: `${build.filePath}:${build.startLine}` },
});
added++;
}
}
return edges;
}
/**
* Phase 4c: C++ virtual override. A call through a base/interface pointer
* (`db->Get(...)`, `iter->Next()`) dispatches at runtime to a subclass override,
* but that hop is a vtable indirection — no static call edge — so a flow stops at
* the abstract base method. Bridge it like react-render: for each C++ class that
* `extends` a base, link each base method → the subclass method of the same name
* (the override), so trace/callees from the interface method reach the
* implementation(s). Over-approximation accepted (reachability-correct); capped
* per class and gated to C++ to avoid touching other languages' dispatch.
*/
function cppOverrideEdges(queries: QueryBuilder): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
const methodsOf = (classId: string): Node[] =>
queries
.getOutgoingEdges(classId, ['contains'])
.map((e) => queries.getNodeById(e.target))
.filter((n): n is Node => !!n && n.kind === 'method');
for (const cls of queries.getNodesByKind('class')) {
const subMethods = methodsOf(cls.id).filter((n) => n.language === 'cpp');
if (subMethods.length === 0) continue;
for (const ext of queries.getOutgoingEdges(cls.id, ['extends'])) {
const base = queries.getNodeById(ext.target);
if (!base || base.language !== 'cpp' || base.id === cls.id) continue;
const baseMethods = new Map(methodsOf(base.id).map((m) => [m.name, m]));
let added = 0;
for (const m of subMethods) {
if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
const bm = baseMethods.get(m.name);
if (!bm || bm.id === m.id) continue;
const key = `${bm.id}>${m.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: bm.id,
target: m.id,
kind: 'calls',
line: bm.startLine,
provenance: 'heuristic',
metadata: { synthesizedBy: 'cpp-override', via: m.name, registeredAt: `${m.filePath}:${m.startLine}` },
});
added++;
}
}
}
return edges;
}
/**
* Phase 5.5: interface / abstract dispatch (Java, Kotlin). A call through an
* injected interface (`@Autowired FooService svc; svc.list()`) or an abstract
* base dispatches at runtime to the implementing class's override — a vtable
* indirection with no static call edge — so a request→service flow stops at the
* interface method. Bridge it like cpp-override: for each class that
* `implements` an interface (or `extends` an abstract base), link each
* base/interface method → the class's same-name method (the override) so
* trace/callees reach the implementation. Over-approximation accepted
* (reachability-correct); capped per class, gated to JVM languages.
*/
// Languages whose static `implements`/`extends` edges should bridge an
// interface (or abstract base) method to the matching concrete-class method.
// The set is "languages with explicit nominal subtyping and a single class
// kind that holds methods" — i.e. the shape this loop expects. Swift and
// Scala fit shape-wise (Swift `protocol`/`class`, Scala `trait`/`class`)
// and are added below; their concrete-side nodes can be a `struct` (Swift)
// or an `object` (Scala) so the loop also iterates those kinds.
const IFACE_OVERRIDE_LANGS = new Set([
'java', 'kotlin', 'csharp', 'typescript', 'javascript', 'swift', 'scala',
]);
function interfaceOverrideEdges(queries: QueryBuilder): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
const methodsOf = (classId: string): Node[] =>
queries
.getOutgoingEdges(classId, ['contains'])
.map((e) => queries.getNodeById(e.target))
.filter((n): n is Node => !!n && n.kind === 'method');
// Concrete-side kinds vary by language: `class` covers Java / Kotlin /
// C# / TS / Swift-classes / Scala-classes; `struct` covers Swift value
// types that conform to protocols. Iterate both.
const concreteKinds = ['class', 'struct'] as const;
for (const kind of concreteKinds) {
for (const cls of queries.getNodesByKind(kind)) {
const implMethods = methodsOf(cls.id).filter((n) => IFACE_OVERRIDE_LANGS.has(n.language));
if (implMethods.length === 0) continue;
for (const sup of queries.getOutgoingEdges(cls.id, ['implements', 'extends'])) {
const base = queries.getNodeById(sup.target);
if (!base || !IFACE_OVERRIDE_LANGS.has(base.language) || base.id === cls.id) continue;
// Group impl methods by name to handle OVERLOADS: an interface `list()` and
// `list(params)` are distinct nodes and a call may resolve to either, so
// link every base overload → every same-name impl overload (keying by name
// alone would drop all but one and miss the resolved overload).
const implByName = new Map<string, Node[]>();
for (const m of implMethods) {
const arr = implByName.get(m.name);
if (arr) arr.push(m); else implByName.set(m.name, [m]);
}
let added = 0;
for (const bm of methodsOf(base.id)) {
if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
for (const m of implByName.get(bm.name) ?? []) {
if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
if (bm.id === m.id) continue;
const key = `${bm.id}>${m.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: bm.id,
target: m.id,
kind: 'calls',
line: bm.startLine,
provenance: 'heuristic',
metadata: { synthesizedBy: 'interface-impl', via: m.name, registeredAt: `${m.filePath}:${m.startLine}` },
});
added++;
}
}
}
}
}
return edges;
}
/**
* Go gRPC stub → impl bridge. The protoc-gen-go-grpc codegen emits an
* `UnimplementedXxxServer` struct in `*_grpc.pb.go` carrying one method
* per service RPC; the real handler is a hand-written struct in another
* file (`x/bank/keeper/msg_server.go::msgServer.Send` in cosmos-sdk).
* Go's structural typing means no `implements` edge exists for our
* resolver to follow, so `trace("Send","SendCoins")` lands on the
* empty stub and reports "no path" (validated empirically — the cosmos
* Q1 r1 trace failure that drove this work).
*
* Bridge: for each `UnimplementedXxxServer` whose RPC-method names are
* a SUBSET of some other Go struct's method names, emit `calls` edges
* `stub.method → impl.method` (paired by name). Excludes the gRPC
* internal markers `mustEmbedUnimplementedXxxServer` and
* `testEmbeddedByValue`, and skips candidate impls that themselves
* live in a generated file (their `xxxClient` / sibling stubs would
* otherwise look like impls).
*
* Multiple candidates is allowed and capped at MAX_CALLBACKS_PER_CHANNEL —
* a service often has both a production impl and one or more test
* mocks; linking to all preserves trace utility without false-favoring.
*
* Provenance: `heuristic`, `synthesizedBy: 'go-grpc-stub-impl'`. The
* stub's source line is the wiring site shown in the trace trail.
*/
function goGrpcStubImplEdges(queries: QueryBuilder): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
const STUB_RE = /^Unimplemented.*Server$/;
// gRPC internal-helper methods that appear on every Unimplemented*Server;
// not part of the service contract, so exclude when computing the RPC-method
// signature used to match impls.
const isInternalMarker = (n: string) => n.startsWith('mustEmbed') || n === 'testEmbeddedByValue';
// Methods directly contained by each Go struct, name-only. Built once.
const methodNamesByStruct = new Map<string, Set<string>>();
const methodNodesByStruct = new Map<string, Node[]>();
const goStructs: Node[] = [];
for (const s of queries.getNodesByKind('struct')) {
if (s.language !== 'go') continue;
goStructs.push(s);
const ms = queries
.getOutgoingEdges(s.id, ['contains'])
.map((e) => queries.getNodeById(e.target))
.filter((n): n is Node => !!n && n.kind === 'method');
methodNodesByStruct.set(s.id, ms);
methodNamesByStruct.set(s.id, new Set(ms.map((m) => m.name)));
}
for (const stub of goStructs) {
if (!STUB_RE.test(stub.name)) continue;
// The stub MUST live in a generated file — that's what tells us this is
// a protoc-emitted scaffold rather than someone naming a struct
// `UnimplementedXxxServer` by hand. Without this gate we'd also bridge
// such hand-written structs and create misleading edges.
if (!isGeneratedFile(stub.filePath)) continue;
const stubMethods = (methodNodesByStruct.get(stub.id) ?? []).filter(
(m) => !isInternalMarker(m.name),
);
if (stubMethods.length === 0) continue;
const stubMethodNames = stubMethods.map((m) => m.name);
for (const cand of goStructs) {
if (cand.id === stub.id) continue;
// Skip generated-file candidates — they're siblings (msgClient,
// UnsafeMsgServer, …) whose method sets coincidentally match.
if (isGeneratedFile(cand.filePath)) continue;
const candNames = methodNamesByStruct.get(cand.id);
if (!candNames) continue;
// Subset: every RPC method must exist on the candidate by name.
// Signature-level match would tighten this further, but name-match
// alone already gives one-to-one pairing in real codebases because
// gRPC method-name sets are highly distinctive (Send + MultiSend +
// UpdateParams + SetSendEnabled is unique to bank's MsgServer).
if (!stubMethodNames.every((n) => candNames.has(n))) continue;
const candMethods = methodNodesByStruct.get(cand.id) ?? [];
let added = 0;
for (const sm of stubMethods) {
if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
for (const cm of candMethods) {
if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
if (cm.name !== sm.name) continue;
const key = `${sm.id}>${cm.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: sm.id,
target: cm.id,
kind: 'calls',
line: sm.startLine,
provenance: 'heuristic',
metadata: {
synthesizedBy: 'go-grpc-stub-impl',
via: cm.name,
registeredAt: `${cm.filePath}:${cm.startLine}`,
},
});
added++;
}
}
}
}
return edges;
}
/**
* Phase 5: React JSX child rendering. A component that returns `<Child .../>`
* mounts Child — React calls it — but JSX instantiation isn't a static call edge,
* so a render tree (App.render → StaticCanvas → renderStaticScene) breaks at the
* JSX hop. Link parent → each capitalized JSX child it renders. File-oriented
* (read each JSX file once). Precision gate: the child name must resolve to a
* component/function/class node — TS generics like `Array<Foo>` resolve to a type
* (or nothing) and are dropped.
*/
function reactJsxChildEdges(ctx: ResolutionContext): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
const PARENT_KINDS = new Set(['method', 'function', 'component']);
for (const file of ctx.getAllFiles()) {
const content = ctx.readFile(file);
if (!content || (!content.includes('</') && !content.includes('/>'))) continue; // JSX-file gate
const parents = ctx.getNodesInFile(file).filter((n) => PARENT_KINDS.has(n.kind));
for (const parent of parents) {
const src = sliceLines(content, parent.startLine, parent.endLine);
if (!src || (!src.includes('</') && !src.includes('/>'))) continue;
const names = new Set<string>();
JSX_TAG_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = JSX_TAG_RE.exec(src))) names.add(m[1]!);
let added = 0;
for (const name of names) {
if (added >= MAX_JSX_CHILDREN) break;
const child = ctx.getNodesByName(name).find(
(n) => n.kind === 'component' || n.kind === 'function' || n.kind === 'class'
);
if (!child || child.id === parent.id) continue;
const key = `${parent.id}>${child.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: parent.id, target: child.id, kind: 'calls', line: parent.startLine,
provenance: 'heuristic',
metadata: { synthesizedBy: 'jsx-render', via: name },
});
added++;
}
}
}
return edges;
}
/**
* Phase 6: Vue SFC templates. The `.vue` extractor only parses `<script>`, so
* template usage is invisible — child components and event handlers used ONLY in
* the template have no edge to them. PascalCase children (`<VPNav/>`) are already
* caught by reactJsxChildEdges (which scans the SFC component node), so this adds
* the two Vue-specific shapes:
* - kebab-case children: `<el-button>` → `ElButton` component (renders).
* - event bindings: `@click="onClick"` / `v-on:submit="save"` → handler method.
* Scoped to the `<template>` block of `.vue` files; resolution gate (kebab→
* component, handler→function/method) keeps precision; inline arrows / `$emit`
* skipped.
*/
function vueTemplateEdges(ctx: ResolutionContext): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
const COMPONENT_KINDS = new Set(['component', 'function', 'class']);
const HANDLER_KINDS = new Set(['method', 'function']);
// A composable's returned member may be a fn (`function close(){}`) or an
// arrow assigned to a const (`const close = () => {}`).
const RETURN_KINDS = new Set(['method', 'function', 'variable', 'constant']);
for (const file of ctx.getAllFiles()) {
if (!file.endsWith('.vue')) continue;
const content = ctx.readFile(file);
const tpl = content && content.match(/<template[^>]*>([\s\S]*)<\/template>/i)?.[1];
if (!tpl) continue;
const comp = ctx.getNodesInFile(file).find((n) => n.kind === 'component');
if (!comp) continue;
// Composable-destructure map: alias → { composable, key }. Lets us resolve a
// template handler that isn't a local function but a destructured composable
// return (`@click="closeSidebar"` ← `const { close: closeSidebar } = useSidebarControl()`).
const script = content.match(/<script[^>]*>([\s\S]*?)<\/script>/i)?.[1] ?? '';
const destructured = new Map<string, { composable: string; key: string }>();
VUE_DESTRUCTURE_RE.lastIndex = 0;
let dm: RegExpExecArray | null;
while ((dm = VUE_DESTRUCTURE_RE.exec(script))) {
if (!/^use[A-Z]/.test(dm[2]!)) continue; // composables / hooks only
for (const part of dm[1]!.split(',')) {
const pm = part.trim().match(/^(\w+)\s*(?::\s*(\w+))?$/); // key | key: alias
if (pm) destructured.set(pm[2] || pm[1]!, { composable: dm[2]!, key: pm[1]! });
}
}
let added = 0;
const addEdge = (target: Node | undefined, meta: Record<string, unknown>) => {
if (added >= MAX_JSX_CHILDREN || !target || target.id === comp.id) return;
const k = `${comp.id}>${target.id}>${meta.synthesizedBy}`;
if (seen.has(k)) return;
seen.add(k);
edges.push({ source: comp.id, target: target.id, kind: 'calls', line: comp.startLine, provenance: 'heuristic', metadata: meta });
added++;
};
// Prefer a target in THIS SFC (handlers live in the same file's script) —
// avoids cross-file mis-match when a name repeats across a monorepo.
const resolve = (name: string, kinds: Set<string>): Node | undefined => {
const matches = ctx.getNodesByName(name).filter((n) => kinds.has(n.kind));
return matches.find((n) => n.filePath === file) ?? matches[0];
};
let m: RegExpExecArray | null;
VUE_KEBAB_RE.lastIndex = 0;
while ((m = VUE_KEBAB_RE.exec(tpl))) addEdge(resolve(kebabToPascal(m[1]!), COMPONENT_KINDS), { synthesizedBy: 'jsx-render', via: m[1] });
VUE_HANDLER_RE.lastIndex = 0;
while ((m = VUE_HANDLER_RE.exec(tpl))) {
const event = m[1]!;
const expr = m[2]!.trim();
if (expr.includes('=>') || expr.startsWith('$')) continue; // inline arrow / $emit
const name = expr.match(/^([A-Za-z_]\w*)/)?.[1];
if (!name) continue;
const direct = resolve(name, HANDLER_KINDS);
if (direct) { addEdge(direct, { synthesizedBy: 'vue-handler', event }); continue; }
// Composable-destructure handler → resolve to the composable's returned fn.
const d = destructured.get(name);
if (!d) continue;
const composable = resolve(d.composable, HANDLER_KINDS);
// Resolve to the SPECIFIC returned member (e.g. `close`) defined in the
// composable's file. No fallback to the composable itself — the component
// already has a static `useX()` call edge, so that would just be redundant
// and less precise.
const keyFn = composable
? ctx.getNodesByName(d.key).find((n) => RETURN_KINDS.has(n.kind) && n.filePath === composable.filePath)
: undefined;
if (keyFn) addEdge(keyFn, { synthesizedBy: 'vue-handler', event, via: d.composable });
}
}
return edges;
}
/**
* React Native cross-language event channel (Phase 3 of the mixed-iOS/RN
* bridging effort). Same shape as `eventEmitterEdges` but cross-language:
*
* Native (ObjC, on RCTEventEmitter subclass):
* [self sendEventWithName:@"locationUpdate" body:@{...}];
*
* Native (Java/Kotlin, via the JS module dispatcher):
* emitter.emit("locationUpdate", body);
* reactContext.getJSModule(RCTDeviceEventEmitter.class).emit("locationUpdate", body);
*
* JS (subscriber):
* new NativeEventEmitter(NativeModules.Geo).addListener("locationUpdate", handler);
* DeviceEventEmitter.addListener("locationUpdate", handler);
*
* Synthesize: native dispatch site → JS handler, keyed by the literal
* event name. Only matches NAMED handlers (the existing `ON_RE` named-
* capture form). Inline arrow handlers like `addListener('x', d => …)`
* aren't named at extraction time and would need link-through-body
* support; matches the deliberate scope of the in-language synthesizer.
*
* Provenance `'heuristic'`, synthesizedBy `'rn-event-channel'`.
*/
// ObjC's `[self sendEventWithName:@"X" body:...]` shape (bracket syntax,
// `@` string literals).
const RN_OBJC_SEND_RE = /\bsendEventWithName\s*:\s*@"([^"]+)"/g;
// Swift's `sendEvent(withName: "X", body: ...)` shape — same RCTEventEmitter
// method, different call syntax. Both Objective-C and Swift subclass
// RCTEventEmitter so this catches the Swift-side equivalent emission sites
// (e.g. RNFusedLocation.swift's `sendEvent(withName: "geolocationDidChange",
// body: locationData)`).
const RN_SWIFT_SEND_RE = /\bsendEvent\s*\(\s*withName\s*:\s*"([^"]+)"/g;
// JVM-side emitter calls: `emitter.emit("X", body)`. Matches both Java
// and Kotlin syntax because the call form is identical. Restricted to
// JVM source files in the consumer so we don't re-process JS emits
// (which `eventEmitterEdges` already handles).
const RN_JVM_EMIT_RE = /\.emit\s*\(\s*"([^"]+)"\s*,/g;
function rnEventEdges(ctx: ResolutionContext): Edge[] {
// Native dispatchers (source = the native method whose body sends the
// event) and JS handlers (target = the function/method registered as
// the listener) keyed by event name.
const nativeDispatchersByEvent = new Map<string, Set<string>>();
const jsHandlersByEvent = new Map<string, Map<string, string>>();
for (const file of ctx.getAllFiles()) {
const content = ctx.readFile(file);
if (!content) continue;
const nodesInFile = ctx.getNodesInFile(file);
const lineOf = (idx: number) => content.slice(0, idx).split('\n').length;
const addDispatcher = (event: string, line: number) => {
const disp = enclosingFn(nodesInFile, line);
if (!disp) return;
const set = nativeDispatchersByEvent.get(event) ?? new Set<string>();
set.add(disp.id);
nativeDispatchersByEvent.set(event, set);
};
// ObjC side: `sendEventWithName:@"X"` only fires inside `.m`/`.mm`
// files (RCTEventEmitter subclasses).
if (file.endsWith('.m') || file.endsWith('.mm')) {
RN_OBJC_SEND_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = RN_OBJC_SEND_RE.exec(content))) {
if (m[1]) addDispatcher(m[1], lineOf(m.index));
}
}
// Swift side: same RCTEventEmitter method, parens/named-args syntax.
if (file.endsWith('.swift')) {
RN_SWIFT_SEND_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = RN_SWIFT_SEND_RE.exec(content))) {
if (m[1]) addDispatcher(m[1], lineOf(m.index));
}
}
// JVM side: `.emit("X", …)` in Java/Kotlin. (We pattern-match
// anywhere in the file; the JS in-language path uses a separate
// emitter object pattern and is already handled by eventEmitterEdges.)
if (file.endsWith('.java') || file.endsWith('.kt')) {
RN_JVM_EMIT_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = RN_JVM_EMIT_RE.exec(content))) {
if (m[1]) addDispatcher(m[1], lineOf(m.index));
}
}
// JS subscribers (.addListener("X", handler)). Restrict to JS-family
// files so a native file's `addListener:` (the ObjC method) doesn't
// get mistaken for a JS subscription — they're entirely different
// things despite sharing a name.
if (
file.endsWith('.js') ||
file.endsWith('.jsx') ||
file.endsWith('.ts') ||
file.endsWith('.tsx') ||
file.endsWith('.mjs') ||
file.endsWith('.cjs')
) {
// Match BOTH the named-handler form (`.addListener('x', fn)`) and
// an unnamed-handler form (`.addListener('x', listener)` where
// `listener` is a parameter — common in RN wrapper APIs like
// RNFirebase's `messaging().onMessageReceived(listener)`). For the
// unnamed case we attribute the subscription to the ENCLOSING JS
// function (the abstraction layer), giving a reachability-correct
// hop even when the actual user-side handler lives one call up.
const ADDLISTENER_ANY = /\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*([A-Za-z_][\w.]*)/g;
ADDLISTENER_ANY.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ADDLISTENER_ANY.exec(content))) {
const event = m[1];
const arg = m[2];
if (!event || !arg) continue;
const bareName = arg.includes('.') ? arg.slice(arg.lastIndexOf('.') + 1) : arg;
// Try a named-symbol match first (matches the in-language semantic).
const namedHandler = ctx
.getNodesByName(bareName)
.find((n) => n.kind === 'function' || n.kind === 'method');
let targetId: string | null = namedHandler?.id ?? null;
if (!targetId) {
// Fall back to the enclosing function — the subscribe-wrapper
// pattern means the event fires THROUGH this function on its
// way to user code. Reachability-correct attribution.
const enclosing = enclosingFn(nodesInFile, lineOf(m.index));
targetId = enclosing?.id ?? null;
}
if (!targetId) {
// Broader fallback for JS object-literal API shape
// (`const Foo = { watchX(...) { … addListener(...) … } }`):
// method shorthand inside an object literal isn't extracted
// as a method node, so enclosingFn returns null. Attribute to
// the smallest enclosing `constant` / `variable` node — that's
// the API surface a downstream caller would `import` and
// invoke. Reachability-correct.
const line = lineOf(m.index);
let smallest: typeof nodesInFile[number] | null = null;
for (const n of nodesInFile) {
if (n.kind !== 'constant' && n.kind !== 'variable') continue;
const end = n.endLine ?? n.startLine;
if (n.startLine <= line && end >= line) {
if (!smallest || n.startLine >= smallest.startLine) smallest = n;
}
}
targetId = smallest?.id ?? null;
}
if (!targetId) continue;
const map = jsHandlersByEvent.get(event) ?? new Map<string, string>();
map.set(targetId, `${file}:${lineOf(m.index)}`);
jsHandlersByEvent.set(event, map);
}
}
}
const edges: Edge[] = [];
const seen = new Set<string>();
for (const [event, dispatchers] of nativeDispatchersByEvent) {
const handlers = jsHandlersByEvent.get(event);
if (!handlers) continue;
// Same fan-out guard as the in-language channel: generic event names
// (e.g. 'change', 'error', 'data') with many handlers/dispatchers
// can't be matched precisely without receiver-type info.
if (dispatchers.size > EVENT_FANOUT_CAP || handlers.size > EVENT_FANOUT_CAP) continue;
for (const d of dispatchers) {
for (const [h, registeredAt] of handlers) {
if (d === h) continue;
const key = `${d}>${h}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: d,
target: h,
kind: 'calls',
provenance: 'heuristic',
metadata: { synthesizedBy: 'rn-event-channel', event, registeredAt },
});
}
}
}
return edges;
}
/**
* Phase 6 — React Native Fabric/Codegen view component bridge.
*
* The Fabric framework extractor (`frameworks/fabric.ts`) emits
* `component` nodes named after the JS-visible component (e.g.
* `RNSScreenStack`) from each `codegenNativeComponent<Props>('Name')`
* spec declaration. The native implementation lives in an ObjC++/.mm or
* Kotlin/Java class whose name follows one of RN's conventions:
*
* - Exact: `RNSScreenStack`
* - With suffix: `RNSScreenStackView`, `RNSScreenStackViewManager`,
* `RNSScreenStackComponentView`, `RNSScreenStackManager`
*
* This synthesizer walks every Fabric component node and looks for a
* native class matching one of those names; when found, emits a
* `calls` edge `component → native class` (provenance `'heuristic'`,
* `synthesizedBy:'fabric-native-impl'`) so trace from JSX usage of the
* component continues into native.
*
* The convention-based suffix lookup is precise: there's no name
* collision in RN view-manager codebases by design (Codegen output would
* conflict otherwise).
*/
const FABRIC_NATIVE_SUFFIXES = ['', 'View', 'ViewManager', 'ComponentView', 'Manager'];
function fabricNativeImplEdges(ctx: ResolutionContext): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
// The Fabric extractor IDs are prefixed `fabric-component:` so we can
// filter to just those without iterating all `component` nodes.
const components = ctx.getNodesByKind('component').filter((n) => n.id.startsWith('fabric-component:'));
if (components.length === 0) return edges;
// Pre-index native classes by name for O(1) lookup.
const nativeClassesByName = new Map<string, Node[]>();
for (const n of ctx.getNodesByKind('class')) {
if (n.language !== 'objc' && n.language !== 'kotlin' && n.language !== 'java' && n.language !== 'cpp') continue;
const arr = nativeClassesByName.get(n.name);
if (arr) arr.push(n);
else nativeClassesByName.set(n.name, [n]);
}
for (const component of components) {
for (const suffix of FABRIC_NATIVE_SUFFIXES) {
const candidate = component.name + suffix;
const matches = nativeClassesByName.get(candidate);
if (!matches || matches.length === 0) continue;
// Link the component node to every matching native class (iOS +
// Android each have one).
for (const native of matches) {
const key = `${component.id}>${native.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: component.id,
target: native.id,
kind: 'calls',
provenance: 'heuristic',
metadata: {
synthesizedBy: 'fabric-native-impl',
viaSuffix: suffix || '(exact)',
componentName: component.name,
},
});
}
}
}
return edges;
}
/**
* MyBatis: link a Java mapper interface method to the XML statement that holds
* its SQL. The XML extractor (`src/extraction/mybatis-extractor.ts`) qualifies
* each `<select|insert|update|delete|sql id="X">` as `<namespace>::<id>` where
* `<namespace>` is the Java FQN of the mapper interface. A Java method's
* qualifiedName ends with `<ClassName>::<methodName>`, so we suffix-match the
* last two segments of the XML qualified name to find a unique Java method by
* `<ClassName>::<methodName>` (`ClassName` = last dotted segment of the XML
* namespace). Cross-mapper `<include refid="other.X">` references go through