@optave/codegraph 3.11.1 → 3.12.0

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.
Files changed (176) hide show
  1. package/README.md +8 -8
  2. package/dist/db/migrations.d.ts.map +1 -1
  3. package/dist/db/migrations.js +7 -0
  4. package/dist/db/migrations.js.map +1 -1
  5. package/dist/domain/analysis/module-map.d.ts +2 -0
  6. package/dist/domain/analysis/module-map.d.ts.map +1 -1
  7. package/dist/domain/analysis/module-map.js +24 -2
  8. package/dist/domain/analysis/module-map.js.map +1 -1
  9. package/dist/domain/graph/builder/call-resolver.d.ts +73 -0
  10. package/dist/domain/graph/builder/call-resolver.d.ts.map +1 -0
  11. package/dist/domain/graph/builder/call-resolver.js +292 -0
  12. package/dist/domain/graph/builder/call-resolver.js.map +1 -0
  13. package/dist/domain/graph/builder/cha.d.ts +61 -0
  14. package/dist/domain/graph/builder/cha.d.ts.map +1 -0
  15. package/dist/domain/graph/builder/cha.js +143 -0
  16. package/dist/domain/graph/builder/cha.js.map +1 -0
  17. package/dist/domain/graph/builder/context.d.ts +3 -0
  18. package/dist/domain/graph/builder/context.d.ts.map +1 -1
  19. package/dist/domain/graph/builder/context.js +2 -0
  20. package/dist/domain/graph/builder/context.js.map +1 -1
  21. package/dist/domain/graph/builder/helpers.d.ts +17 -1
  22. package/dist/domain/graph/builder/helpers.d.ts.map +1 -1
  23. package/dist/domain/graph/builder/helpers.js +159 -5
  24. package/dist/domain/graph/builder/helpers.js.map +1 -1
  25. package/dist/domain/graph/builder/incremental.d.ts.map +1 -1
  26. package/dist/domain/graph/builder/incremental.js +147 -54
  27. package/dist/domain/graph/builder/incremental.js.map +1 -1
  28. package/dist/domain/graph/builder/stages/build-edges.d.ts +2 -0
  29. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  30. package/dist/domain/graph/builder/stages/build-edges.js +932 -110
  31. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  32. package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
  33. package/dist/domain/graph/builder/stages/detect-changes.js +2 -1
  34. package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
  35. package/dist/domain/graph/builder/stages/native-orchestrator.d.ts.map +1 -1
  36. package/dist/domain/graph/builder/stages/native-orchestrator.js +501 -14
  37. package/dist/domain/graph/builder/stages/native-orchestrator.js.map +1 -1
  38. package/dist/domain/graph/builder/stages/resolve-imports.d.ts +1 -0
  39. package/dist/domain/graph/builder/stages/resolve-imports.d.ts.map +1 -1
  40. package/dist/domain/graph/builder/stages/resolve-imports.js +9 -0
  41. package/dist/domain/graph/builder/stages/resolve-imports.js.map +1 -1
  42. package/dist/domain/graph/journal.js +1 -1
  43. package/dist/domain/graph/journal.js.map +1 -1
  44. package/dist/domain/graph/resolver/points-to.d.ts +53 -0
  45. package/dist/domain/graph/resolver/points-to.d.ts.map +1 -0
  46. package/dist/domain/graph/resolver/points-to.js +213 -0
  47. package/dist/domain/graph/resolver/points-to.js.map +1 -0
  48. package/dist/domain/graph/resolver/ts-resolver.d.ts +9 -0
  49. package/dist/domain/graph/resolver/ts-resolver.d.ts.map +1 -0
  50. package/dist/domain/graph/resolver/ts-resolver.js +476 -0
  51. package/dist/domain/graph/resolver/ts-resolver.js.map +1 -0
  52. package/dist/domain/graph/watcher.d.ts.map +1 -1
  53. package/dist/domain/graph/watcher.js +5 -2
  54. package/dist/domain/graph/watcher.js.map +1 -1
  55. package/dist/domain/parser.d.ts +10 -1
  56. package/dist/domain/parser.d.ts.map +1 -1
  57. package/dist/domain/parser.js +39 -7
  58. package/dist/domain/parser.js.map +1 -1
  59. package/dist/domain/wasm-worker-entry.js +25 -0
  60. package/dist/domain/wasm-worker-entry.js.map +1 -1
  61. package/dist/domain/wasm-worker-pool.d.ts.map +1 -1
  62. package/dist/domain/wasm-worker-pool.js +32 -0
  63. package/dist/domain/wasm-worker-pool.js.map +1 -1
  64. package/dist/domain/wasm-worker-protocol.d.ts +14 -1
  65. package/dist/domain/wasm-worker-protocol.d.ts.map +1 -1
  66. package/dist/extractors/c.js +3 -3
  67. package/dist/extractors/c.js.map +1 -1
  68. package/dist/extractors/clojure.js +1 -1
  69. package/dist/extractors/clojure.js.map +1 -1
  70. package/dist/extractors/cpp.js +3 -3
  71. package/dist/extractors/cpp.js.map +1 -1
  72. package/dist/extractors/csharp.d.ts.map +1 -1
  73. package/dist/extractors/csharp.js +37 -8
  74. package/dist/extractors/csharp.js.map +1 -1
  75. package/dist/extractors/cuda.js +3 -3
  76. package/dist/extractors/cuda.js.map +1 -1
  77. package/dist/extractors/elixir.js +6 -6
  78. package/dist/extractors/elixir.js.map +1 -1
  79. package/dist/extractors/fsharp.js +1 -1
  80. package/dist/extractors/fsharp.js.map +1 -1
  81. package/dist/extractors/go.js +5 -5
  82. package/dist/extractors/go.js.map +1 -1
  83. package/dist/extractors/haskell.js +1 -1
  84. package/dist/extractors/haskell.js.map +1 -1
  85. package/dist/extractors/java.js +2 -2
  86. package/dist/extractors/java.js.map +1 -1
  87. package/dist/extractors/javascript.d.ts +2 -0
  88. package/dist/extractors/javascript.d.ts.map +1 -1
  89. package/dist/extractors/javascript.js +1674 -64
  90. package/dist/extractors/javascript.js.map +1 -1
  91. package/dist/extractors/kotlin.js +5 -5
  92. package/dist/extractors/kotlin.js.map +1 -1
  93. package/dist/extractors/lua.js +1 -1
  94. package/dist/extractors/lua.js.map +1 -1
  95. package/dist/extractors/objc.js +3 -3
  96. package/dist/extractors/objc.js.map +1 -1
  97. package/dist/extractors/ocaml.js +1 -1
  98. package/dist/extractors/ocaml.js.map +1 -1
  99. package/dist/extractors/php.js +2 -2
  100. package/dist/extractors/php.js.map +1 -1
  101. package/dist/extractors/python.js +7 -7
  102. package/dist/extractors/python.js.map +1 -1
  103. package/dist/extractors/ruby.js +2 -2
  104. package/dist/extractors/ruby.js.map +1 -1
  105. package/dist/extractors/scala.js +1 -1
  106. package/dist/extractors/scala.js.map +1 -1
  107. package/dist/extractors/solidity.js +1 -1
  108. package/dist/extractors/solidity.js.map +1 -1
  109. package/dist/extractors/swift.js +4 -4
  110. package/dist/extractors/swift.js.map +1 -1
  111. package/dist/extractors/zig.js +4 -4
  112. package/dist/extractors/zig.js.map +1 -1
  113. package/dist/features/structure.d.ts.map +1 -1
  114. package/dist/features/structure.js +121 -16
  115. package/dist/features/structure.js.map +1 -1
  116. package/dist/infrastructure/config.d.ts +10 -0
  117. package/dist/infrastructure/config.d.ts.map +1 -1
  118. package/dist/infrastructure/config.js +15 -0
  119. package/dist/infrastructure/config.js.map +1 -1
  120. package/dist/infrastructure/native.d.ts +11 -0
  121. package/dist/infrastructure/native.d.ts.map +1 -1
  122. package/dist/infrastructure/native.js +78 -5
  123. package/dist/infrastructure/native.js.map +1 -1
  124. package/dist/presentation/queries-cli/overview.d.ts.map +1 -1
  125. package/dist/presentation/queries-cli/overview.js +5 -0
  126. package/dist/presentation/queries-cli/overview.js.map +1 -1
  127. package/dist/types.d.ts +184 -0
  128. package/dist/types.d.ts.map +1 -1
  129. package/grammars/tree-sitter-erlang.wasm +0 -0
  130. package/package.json +9 -9
  131. package/src/db/migrations.ts +7 -0
  132. package/src/domain/analysis/module-map.ts +29 -1
  133. package/src/domain/graph/builder/call-resolver.ts +351 -0
  134. package/src/domain/graph/builder/cha.ts +175 -0
  135. package/src/domain/graph/builder/context.ts +3 -0
  136. package/src/domain/graph/builder/helpers.ts +175 -5
  137. package/src/domain/graph/builder/incremental.ts +186 -66
  138. package/src/domain/graph/builder/stages/build-edges.ts +1146 -146
  139. package/src/domain/graph/builder/stages/detect-changes.ts +3 -1
  140. package/src/domain/graph/builder/stages/native-orchestrator.ts +583 -20
  141. package/src/domain/graph/builder/stages/resolve-imports.ts +14 -0
  142. package/src/domain/graph/journal.ts +1 -1
  143. package/src/domain/graph/resolver/points-to.ts +254 -0
  144. package/src/domain/graph/resolver/ts-resolver.ts +536 -0
  145. package/src/domain/graph/watcher.ts +4 -2
  146. package/src/domain/parser.ts +43 -5
  147. package/src/domain/wasm-worker-entry.ts +25 -0
  148. package/src/domain/wasm-worker-pool.ts +21 -0
  149. package/src/domain/wasm-worker-protocol.ts +14 -0
  150. package/src/extractors/c.ts +3 -3
  151. package/src/extractors/clojure.ts +1 -1
  152. package/src/extractors/cpp.ts +3 -3
  153. package/src/extractors/csharp.ts +33 -9
  154. package/src/extractors/cuda.ts +3 -3
  155. package/src/extractors/elixir.ts +6 -6
  156. package/src/extractors/fsharp.ts +1 -1
  157. package/src/extractors/go.ts +5 -5
  158. package/src/extractors/haskell.ts +1 -1
  159. package/src/extractors/java.ts +2 -2
  160. package/src/extractors/javascript.ts +1802 -66
  161. package/src/extractors/kotlin.ts +5 -5
  162. package/src/extractors/lua.ts +1 -1
  163. package/src/extractors/objc.ts +3 -3
  164. package/src/extractors/ocaml.ts +1 -1
  165. package/src/extractors/php.ts +2 -2
  166. package/src/extractors/python.ts +7 -7
  167. package/src/extractors/ruby.ts +2 -2
  168. package/src/extractors/scala.ts +1 -1
  169. package/src/extractors/solidity.ts +1 -1
  170. package/src/extractors/swift.ts +4 -4
  171. package/src/extractors/zig.ts +4 -4
  172. package/src/features/structure.ts +143 -23
  173. package/src/infrastructure/config.ts +15 -0
  174. package/src/infrastructure/native.ts +87 -5
  175. package/src/presentation/queries-cli/overview.ts +15 -1
  176. package/src/types.ts +194 -0
@@ -7,27 +7,42 @@
7
7
  import path from 'node:path';
8
8
  import { performance } from 'node:perf_hooks';
9
9
  import { getNodeId } from '../../../../db/index.js';
10
+ import { setTypeMapEntry } from '../../../../extractors/helpers.js';
11
+ import { PROPAGATION_HOP_PENALTY } from '../../../../extractors/javascript.js';
10
12
  import { debug } from '../../../../infrastructure/logger.js';
11
13
  import { loadNative } from '../../../../infrastructure/native.js';
12
14
  import type {
13
15
  BetterSqlite3Database,
14
16
  Call,
15
17
  ClassRelation,
18
+ Definition,
16
19
  ExtractorOutput,
20
+ FnRefBinding,
17
21
  Import,
18
22
  NativeAddon,
19
23
  NodeRow,
20
24
  TypeMapEntry,
21
25
  } from '../../../../types.js';
22
26
  import { computeConfidence } from '../../resolve.js';
27
+ import type { PointsToMap } from '../../resolver/points-to.js';
28
+ import { buildPointsToMap, resolveViaPointsTo } from '../../resolver/points-to.js';
29
+ import { enrichTypeMapWithTsc } from '../../resolver/ts-resolver.js';
30
+ import {
31
+ type CallNodeLookup,
32
+ findCaller,
33
+ isModuleScopedLanguage,
34
+ resolveCallTargets,
35
+ resolveReceiverEdge,
36
+ } from '../call-resolver.js';
37
+ import type { ChaContext } from '../cha.js';
38
+ import { buildChaContext, resolveChaTargets, resolveThisDispatch } from '../cha.js';
23
39
  import type { PipelineContext } from '../context.js';
24
- import { BUILTIN_RECEIVERS, batchInsertEdges } from '../helpers.js';
25
-
26
- import { getResolved, isBarrelFile, resolveBarrelExport } from './resolve-imports.js';
40
+ import { BUILTIN_RECEIVERS, batchInsertEdges, runChaPostPass } from '../helpers.js';
41
+ import { getResolved, isBarrelFile, resolveBarrelExportCached } from './resolve-imports.js';
27
42
 
28
43
  // ── Local types ──────────────────────────────────────────────────────────
29
44
 
30
- type EdgeRowTuple = [number, number, string, number, number];
45
+ type EdgeRowTuple = [number, number, string, number, number, string | null];
31
46
 
32
47
  interface NodeIdStmt {
33
48
  get(name: string, kind: string, file: string, line: number): { id: number } | undefined;
@@ -51,6 +66,8 @@ interface NativeFileEntry {
51
66
  importedNames: Array<{ name: string; file: string }>;
52
67
  classes: ClassRelation[];
53
68
  typeMap: Array<{ name: string; typeName: string; confidence: number }>;
69
+ /** Phase 8.3: function-reference bindings for pts analysis. */
70
+ fnRefBindings?: Array<{ lhs: string; rhs: string; rhsReceiver?: string }>;
54
71
  }
55
72
 
56
73
  /** Shape returned by native buildCallEdges. */
@@ -62,6 +79,9 @@ interface NativeEdge {
62
79
  dynamic: number;
63
80
  }
64
81
 
82
+ /** Phase 8.5: confidence penalty applied to CHA-dispatch edges. */
83
+ export const CHA_DISPATCH_PENALTY = 0.1;
84
+
65
85
  // ── Node lookup setup ───────────────────────────────────────────────────
66
86
 
67
87
  function makeGetNodeIdStmt(db: BetterSqlite3Database): NodeIdStmt {
@@ -113,12 +133,12 @@ function emitTypeOnlySymbolEdges(
113
133
  const cleanName = name.replace(/^\*\s+as\s+/, '');
114
134
  let targetFile = resolvedPath;
115
135
  if (isBarrelFile(ctx, resolvedPath)) {
116
- const actual = resolveBarrelExport(ctx, resolvedPath, cleanName);
136
+ const actual = resolveBarrelExportCached(ctx, resolvedPath, cleanName);
117
137
  if (actual) targetFile = actual;
118
138
  }
119
139
  const candidates = ctx.nodesByNameAndFile.get(`${cleanName}|${targetFile}`);
120
140
  if (candidates && candidates.length > 0) {
121
- allEdgeRows.push([fileNodeId, candidates[0]!.id, 'imports-type', 1.0, 0]);
141
+ allEdgeRows.push([fileNodeId, candidates[0]!.id, 'imports-type', 1.0, 0, null]);
122
142
  }
123
143
  }
124
144
  }
@@ -140,7 +160,7 @@ function emitEdgesForImport(
140
160
  if (!targetRow) return;
141
161
 
142
162
  const edgeKind = importEdgeKind(imp);
143
- allEdgeRows.push([fileNodeId, targetRow.id, edgeKind, 1.0, 0]);
163
+ allEdgeRows.push([fileNodeId, targetRow.id, edgeKind, 1.0, 0, null]);
144
164
 
145
165
  if (imp.typeOnly) {
146
166
  emitTypeOnlySymbolEdges(ctx, imp, resolvedPath, fileNodeId, allEdgeRows);
@@ -184,7 +204,7 @@ function buildBarrelEdges(
184
204
  const resolvedSources = new Set<string>();
185
205
  for (const name of imp.names) {
186
206
  const cleanName = name.replace(/^\*\s+as\s+/, '');
187
- const actualSource = resolveBarrelExport(ctx, resolvedPath, cleanName);
207
+ const actualSource = resolveBarrelExportCached(ctx, resolvedPath, cleanName);
188
208
  if (actualSource && actualSource !== resolvedPath && !resolvedSources.has(actualSource)) {
189
209
  resolvedSources.add(actualSource);
190
210
  const actualRow = getNodeIdStmt.get(actualSource, 'file', actualSource, 0);
@@ -195,7 +215,7 @@ function buildBarrelEdges(
195
215
  : edgeKind === 'dynamic-imports'
196
216
  ? 'dynamic-imports'
197
217
  : 'imports';
198
- edgeRows.push([fileNodeId, actualRow.id, kind, 0.9, 0]);
218
+ edgeRows.push([fileNodeId, actualRow.id, kind, 0.9, 0, null]);
199
219
  }
200
220
  }
201
221
  }
@@ -378,7 +398,70 @@ function buildImportEdgesNative(
378
398
  ) as NativeEdge[];
379
399
 
380
400
  for (const e of nativeEdges) {
381
- allEdgeRows.push([e.sourceId, e.targetId, e.kind, e.confidence, e.dynamic]);
401
+ allEdgeRows.push([e.sourceId, e.targetId, e.kind, e.confidence, e.dynamic, null]);
402
+ }
403
+ }
404
+
405
+ // ── Phase 8.2: Cross-file return-type propagation ───────────────────────
406
+
407
+ /**
408
+ * Augment each file's typeMap with return types from imported functions.
409
+ *
410
+ * The per-file extractor already resolves same-file call assignments (intra-file
411
+ * propagation). This function handles the cross-file case: when a file imports a
412
+ * function from another file and assigns its return value to a variable, we look up
413
+ * the callee's return type in the source file's returnTypeMap and inject it.
414
+ *
415
+ * Called once before call-edge building so both the native and JS paths benefit.
416
+ */
417
+ function propagateReturnTypesAcrossFiles(
418
+ fileSymbols: Map<string, ExtractorOutput>,
419
+ ctx: PipelineContext,
420
+ rootDir: string,
421
+ ): void {
422
+ // Index: filePath → per-file return-type map
423
+ const returnTypeIndex = new Map<string, Map<string, TypeMapEntry>>();
424
+ for (const [relPath, symbols] of fileSymbols) {
425
+ if (symbols.returnTypeMap?.size) returnTypeIndex.set(relPath, symbols.returnTypeMap);
426
+ }
427
+ if (returnTypeIndex.size === 0) return;
428
+
429
+ // Flat global map for qualified method lookups (TypeName.methodName → entry).
430
+ // Conflicts resolved by keeping the highest-confidence entry.
431
+ const globalReturnTypeMap = new Map<string, TypeMapEntry>();
432
+ for (const rtm of returnTypeIndex.values()) {
433
+ for (const [name, entry] of rtm) {
434
+ const existing = globalReturnTypeMap.get(name);
435
+ if (!existing || entry.confidence > existing.confidence) globalReturnTypeMap.set(name, entry);
436
+ }
437
+ }
438
+
439
+ for (const [relPath, symbols] of fileSymbols) {
440
+ if (!symbols.callAssignments?.length) continue;
441
+ // Phase 8.4 side-effect: buildImportedNamesMap now traces through barrel
442
+ // files (traceBarrel), so `importedFrom` resolves to the leaf definition
443
+ // file rather than the barrel. This means returnTypeIndex.get(importedFrom)
444
+ // now finds entries it previously missed, improving cross-file return-type
445
+ // propagation through re-export chains (Phase 8.2 improvement).
446
+ const importedNamesMap = buildImportedNamesMap(ctx, relPath, symbols, rootDir);
447
+
448
+ for (const ca of symbols.callAssignments) {
449
+ if (symbols.typeMap.has(ca.varName)) continue; // already resolved locally
450
+
451
+ let returnEntry: TypeMapEntry | undefined;
452
+ if (ca.receiverTypeName) {
453
+ returnEntry = globalReturnTypeMap.get(`${ca.receiverTypeName}.${ca.calleeName}`);
454
+ } else {
455
+ const importedFrom = importedNamesMap.get(ca.calleeName);
456
+ if (importedFrom) returnEntry = returnTypeIndex.get(importedFrom)?.get(ca.calleeName);
457
+ }
458
+
459
+ if (returnEntry) {
460
+ const propagatedConf = returnEntry.confidence - PROPAGATION_HOP_PENALTY;
461
+ if (propagatedConf > 0)
462
+ setTypeMapEntry(symbols.typeMap, ca.varName, returnEntry.type, propagatedConf);
463
+ }
464
+ }
382
465
  }
383
466
  }
384
467
 
@@ -436,6 +519,7 @@ function buildCallEdgesNative(
436
519
  importedNames,
437
520
  classes: symbols.classes,
438
521
  typeMap,
522
+ fnRefBindings: symbols.fnRefBindings?.length ? symbols.fnRefBindings : undefined,
439
523
  });
440
524
  }
441
525
 
@@ -443,7 +527,537 @@ function buildCallEdgesNative(
443
527
  ...BUILTIN_RECEIVERS,
444
528
  ]) as NativeEdge[];
445
529
  for (const e of nativeEdges) {
446
- allEdgeRows.push([e.sourceId, e.targetId, e.kind, e.confidence, e.dynamic]);
530
+ allEdgeRows.push([
531
+ e.sourceId,
532
+ e.targetId,
533
+ e.kind,
534
+ e.confidence,
535
+ e.dynamic,
536
+ e.kind === 'calls' ? 'ts-native' : null,
537
+ ]);
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Phase 8.3c pts post-pass for the native call-edge path.
543
+ *
544
+ * The native Rust engine builds call edges without knowledge of paramBindings,
545
+ * so `fn()` calls inside higher-order functions are not resolved to their
546
+ * concrete targets. This JS post-pass runs after the native edge pass and adds
547
+ * only the parameter-flow pts edges that the native engine missed.
548
+ *
549
+ * To avoid duplicating edges already emitted by the native engine, the current
550
+ * allEdgeRows snapshot is used to seed a seenByPair set before processing each
551
+ * file.
552
+ */
553
+ function buildParamFlowPtsPostPass(
554
+ ctx: PipelineContext,
555
+ getNodeIdStmt: NodeIdStmt,
556
+ allEdgeRows: EdgeRowTuple[],
557
+ sharedLookup?: CallNodeLookup,
558
+ ): void {
559
+ // Only process files that actually have paramBindings (avoid useless work).
560
+ const filesWithParams = [...ctx.fileSymbols].filter(
561
+ ([, symbols]) => symbols.paramBindings && symbols.paramBindings.length > 0,
562
+ );
563
+ if (filesWithParams.length === 0) return;
564
+
565
+ // Seed seenByPair from the existing rows so we don't duplicate native edges.
566
+ // This is O(|allEdgeRows|) once per post-pass, which is acceptable.
567
+ const seenByPair = new Set<string>();
568
+ for (const [srcId, tgtId] of allEdgeRows) {
569
+ seenByPair.add(`${srcId}|${tgtId}`);
570
+ }
571
+
572
+ const { barrelOnlyFiles, rootDir } = ctx;
573
+ const lookup = sharedLookup ?? makeContextLookup(ctx, getNodeIdStmt);
574
+
575
+ for (const [relPath, symbols] of filesWithParams) {
576
+ if (barrelOnlyFiles.has(relPath)) continue;
577
+ const fileNodeRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
578
+ if (!fileNodeRow) continue;
579
+
580
+ const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir);
581
+ const typeMap: Map<string, TypeMapEntry | string> = symbols.typeMap || new Map();
582
+ const ptsMap = buildPointsToMapForFile(symbols, importedNames);
583
+ if (!ptsMap) continue;
584
+
585
+ for (const call of symbols.calls) {
586
+ if (call.receiver || call.dynamic) continue; // pts post-pass handles only param-flow (non-dynamic)
587
+
588
+ const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow);
589
+ const scopedKey = caller.callerName != null ? `${caller.callerName}::${call.name}` : null;
590
+ if (!scopedKey || !ptsMap.has(scopedKey)) continue;
591
+
592
+ // Only resolve calls that had no direct targets (same guard as buildFileCallEdges).
593
+ const { targets } = resolveCallTargets(
594
+ lookup,
595
+ call,
596
+ relPath,
597
+ importedNames,
598
+ typeMap as Map<string, unknown>,
599
+ );
600
+ if (targets.length > 0) continue;
601
+
602
+ for (const alias of resolveViaPointsTo(scopedKey, ptsMap)) {
603
+ const { targets: aliasTargets, importedFrom: aliasFrom } = resolveCallTargets(
604
+ lookup,
605
+ { name: alias },
606
+ relPath,
607
+ importedNames,
608
+ typeMap as Map<string, unknown>,
609
+ );
610
+ for (const t of aliasTargets) {
611
+ const edgeKey = `${caller.id}|${t.id}`;
612
+ if (t.id !== caller.id && !seenByPair.has(edgeKey)) {
613
+ const conf =
614
+ computeConfidence(relPath, t.file, aliasFrom ?? null) - PROPAGATION_HOP_PENALTY;
615
+ if (conf > 0) {
616
+ seenByPair.add(edgeKey);
617
+ allEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'points-to']);
618
+ }
619
+ }
620
+ }
621
+ }
622
+ }
623
+ }
624
+ }
625
+
626
+ /**
627
+ * bind/alias pts post-pass for the native call-edge path.
628
+ *
629
+ * The native Rust engine has no knowledge of JS-layer fnRefBindings (e.g.
630
+ * `const f = fn.bind(ctx)`), so calls to bind-created aliases are not resolved
631
+ * to their original function on the native path. This JS post-pass runs after
632
+ * the native edge pass and adds only the fnRefBindings-seeded pts edges that the
633
+ * native engine missed.
634
+ *
635
+ * Uses the same seenByPair dedup guard as buildParamFlowPtsPostPass to avoid
636
+ * duplicating edges already emitted by the native engine.
637
+ */
638
+ function buildFnRefBindingsPtsPostPass(
639
+ ctx: PipelineContext,
640
+ getNodeIdStmt: NodeIdStmt,
641
+ allEdgeRows: EdgeRowTuple[],
642
+ sharedLookup?: CallNodeLookup,
643
+ ): void {
644
+ // Only process files that actually have fnRefBindings.
645
+ const filesWithBindings = [...ctx.fileSymbols].filter(
646
+ ([, symbols]) => symbols.fnRefBindings && symbols.fnRefBindings.length > 0,
647
+ );
648
+ if (filesWithBindings.length === 0) return;
649
+
650
+ // Seed seenByPair from the existing rows so we don't duplicate native edges.
651
+ const seenByPair = new Set<string>();
652
+ for (const [srcId, tgtId] of allEdgeRows) {
653
+ seenByPair.add(`${srcId}|${tgtId}`);
654
+ }
655
+
656
+ const { barrelOnlyFiles, rootDir } = ctx;
657
+ const lookup = sharedLookup ?? makeContextLookup(ctx, getNodeIdStmt);
658
+
659
+ for (const [relPath, symbols] of filesWithBindings) {
660
+ if (barrelOnlyFiles.has(relPath)) continue;
661
+ const fileNodeRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
662
+ if (!fileNodeRow) continue;
663
+
664
+ const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir);
665
+ const typeMap: Map<string, TypeMapEntry | string> = symbols.typeMap || new Map();
666
+ const ptsMap = buildPointsToMapForFile(symbols, importedNames);
667
+ if (!ptsMap) continue;
668
+
669
+ // Only resolve calls whose name is an lhs in fnRefBindings — the same
670
+ // narrowed guard used in buildFileCallEdges case (c).
671
+ const fnRefBindingLhs = new Set(symbols.fnRefBindings!.map((b) => b.lhs));
672
+
673
+ for (const call of symbols.calls) {
674
+ if (call.receiver || call.dynamic) continue; // bind aliases are flat-keyed, never dynamic
675
+ if (!fnRefBindingLhs.has(call.name)) continue;
676
+ if (!ptsMap.has(call.name)) continue;
677
+
678
+ const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow);
679
+
680
+ // Only resolve calls that had no direct targets (same guard as buildFileCallEdges).
681
+ const { targets } = resolveCallTargets(
682
+ lookup,
683
+ call,
684
+ relPath,
685
+ importedNames,
686
+ typeMap as Map<string, unknown>,
687
+ );
688
+ if (targets.length > 0) continue;
689
+
690
+ for (const alias of resolveViaPointsTo(call.name, ptsMap)) {
691
+ const { targets: aliasTargets, importedFrom: aliasFrom } = resolveCallTargets(
692
+ lookup,
693
+ { name: alias },
694
+ relPath,
695
+ importedNames,
696
+ typeMap as Map<string, unknown>,
697
+ );
698
+ for (const t of aliasTargets) {
699
+ const edgeKey = `${caller.id}|${t.id}`;
700
+ if (t.id !== caller.id && !seenByPair.has(edgeKey)) {
701
+ const conf =
702
+ computeConfidence(relPath, t.file, aliasFrom ?? null) - PROPAGATION_HOP_PENALTY;
703
+ if (conf > 0) {
704
+ seenByPair.add(edgeKey);
705
+ allEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'points-to']);
706
+ }
707
+ }
708
+ }
709
+ }
710
+ }
711
+ }
712
+ }
713
+
714
+ /**
715
+ * this-rebinding post-pass for the native call-edge path.
716
+ *
717
+ * When `fn.call(namedCtx, ...)` or `fn.apply(namedCtx, ...)` is extracted by the
718
+ * WASM layer, `thisCallBindings` records `{ callee: 'fn', thisArg: 'namedCtx' }`.
719
+ * The native Rust engine has no knowledge of these bindings, so `this()` calls
720
+ * inside `fn` remain unresolved. This JS post-pass adds the missing edges by
721
+ * resolving `this()` calls inside each `fn` that has a thisCallBinding.
722
+ */
723
+ function buildThisCallBindingsPtsPostPass(
724
+ ctx: PipelineContext,
725
+ getNodeIdStmt: NodeIdStmt,
726
+ allEdgeRows: EdgeRowTuple[],
727
+ sharedLookup?: CallNodeLookup,
728
+ ): void {
729
+ const filesWithBindings = [...ctx.fileSymbols].filter(
730
+ ([, symbols]) => symbols.thisCallBindings && symbols.thisCallBindings.length > 0,
731
+ );
732
+ if (filesWithBindings.length === 0) return;
733
+
734
+ const seenByPair = new Set<string>();
735
+ for (const [srcId, tgtId] of allEdgeRows) {
736
+ seenByPair.add(`${srcId}|${tgtId}`);
737
+ }
738
+
739
+ const { barrelOnlyFiles, rootDir } = ctx;
740
+ const lookup = sharedLookup ?? makeContextLookup(ctx, getNodeIdStmt);
741
+
742
+ for (const [relPath, symbols] of filesWithBindings) {
743
+ if (barrelOnlyFiles.has(relPath)) continue;
744
+ const fileNodeRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
745
+ if (!fileNodeRow) continue;
746
+
747
+ const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir);
748
+ const typeMap: Map<string, TypeMapEntry | string> = symbols.typeMap || new Map();
749
+ const ptsMap = buildPointsToMapForFile(symbols, importedNames);
750
+ if (!ptsMap) continue;
751
+
752
+ // Only process calls named 'this' (callee-not-receiver usage)
753
+ for (const call of symbols.calls) {
754
+ if (call.name !== 'this' || call.receiver) continue;
755
+
756
+ const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow);
757
+ if (caller.callerName == null) continue;
758
+
759
+ const scopedKey = `${caller.callerName}::this`;
760
+ if (!ptsMap.has(scopedKey)) continue;
761
+
762
+ for (const alias of resolveViaPointsTo(scopedKey, ptsMap)) {
763
+ const { targets: aliasTargets, importedFrom: aliasFrom } = resolveCallTargets(
764
+ lookup,
765
+ { name: alias },
766
+ relPath,
767
+ importedNames,
768
+ typeMap as Map<string, unknown>,
769
+ );
770
+ for (const t of aliasTargets) {
771
+ const edgeKey = `${caller.id}|${t.id}`;
772
+ if (t.id !== caller.id && !seenByPair.has(edgeKey)) {
773
+ const conf =
774
+ computeConfidence(relPath, t.file, aliasFrom ?? null) - PROPAGATION_HOP_PENALTY;
775
+ if (conf > 0) {
776
+ seenByPair.add(edgeKey);
777
+ allEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'points-to']);
778
+ }
779
+ }
780
+ }
781
+ }
782
+ }
783
+ }
784
+ }
785
+
786
+ /**
787
+ * Phase 8.3f post-pass for the native call-edge path.
788
+ *
789
+ * The native Rust engine builds call edges without knowledge of
790
+ * objectRestParamBindings, so `rest.method()` calls inside functions with
791
+ * object-destructuring rest parameters are not resolved via the typeMap chain.
792
+ * The Rust engine already resolves same-file and directly-imported callees
793
+ * (via steps 1–2 of its resolution logic), so this post-pass only adds edges
794
+ * that require the typeMap-chain path:
795
+ * typeMap[restName] → argName → typeMap[argName.method] → target
796
+ *
797
+ * Mirrors the seeding in buildCallEdgesJS (Phase 8.3f) to ensure both engine
798
+ * paths produce identical results for receiver-typed rest-param calls.
799
+ */
800
+ function buildObjectRestParamPostPass(
801
+ ctx: PipelineContext,
802
+ getNodeIdStmt: NodeIdStmt,
803
+ allEdgeRows: EdgeRowTuple[],
804
+ sharedLookup?: CallNodeLookup,
805
+ ): void {
806
+ const filesWithRestBindings = [...ctx.fileSymbols].filter(
807
+ ([, symbols]) =>
808
+ symbols.objectRestParamBindings &&
809
+ symbols.objectRestParamBindings.length > 0 &&
810
+ symbols.paramBindings &&
811
+ symbols.paramBindings.length > 0,
812
+ );
813
+ if (filesWithRestBindings.length === 0) return;
814
+
815
+ const seenByPair = new Set<string>();
816
+ for (const [srcId, tgtId] of allEdgeRows) {
817
+ seenByPair.add(`${srcId}|${tgtId}`);
818
+ }
819
+
820
+ const { barrelOnlyFiles, rootDir } = ctx;
821
+ const lookup = sharedLookup ?? makeContextLookup(ctx, getNodeIdStmt);
822
+
823
+ for (const [relPath, symbols] of filesWithRestBindings) {
824
+ if (barrelOnlyFiles.has(relPath)) continue;
825
+ const fileNodeRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
826
+ if (!fileNodeRow) continue;
827
+
828
+ const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir);
829
+ const typeMap: Map<string, TypeMapEntry | string> = new Map(
830
+ symbols.typeMap instanceof Map ? symbols.typeMap : [],
831
+ );
832
+
833
+ // Seed typeMap[callee::restName] = { type: argName } for each matching pair.
834
+ // Mirrors the seeding in buildCallEdgesJS Phase 8.3f. Keys are scoped by
835
+ // callee so two functions with the same rest-param name (e.g. `...rest`) in
836
+ // the same file don't collide (#1358).
837
+ // When only one callee uses a given rest name, also seed the unscoped key
838
+ // as a null-callerName fallback so edges aren't silently dropped if
839
+ // findCaller can't identify the enclosing function (#1358).
840
+ const restNameCallees = new Map<string, Set<string>>();
841
+ for (const orpb of symbols.objectRestParamBindings!) {
842
+ if (!restNameCallees.has(orpb.restName)) restNameCallees.set(orpb.restName, new Set());
843
+ restNameCallees.get(orpb.restName)!.add(orpb.callee);
844
+ }
845
+ const restNames = new Set<string>();
846
+ for (const orpb of symbols.objectRestParamBindings!) {
847
+ for (const pb of symbols.paramBindings!) {
848
+ if (pb.callee === orpb.callee && pb.argIndex === orpb.argIndex) {
849
+ const scopedKey = `${orpb.callee}::${orpb.restName}`;
850
+ if (!typeMap.has(scopedKey)) {
851
+ typeMap.set(scopedKey, { type: pb.argName, confidence: 0.65 });
852
+ if (restNameCallees.get(orpb.restName)!.size === 1 && !typeMap.has(orpb.restName)) {
853
+ typeMap.set(orpb.restName, { type: pb.argName, confidence: 0.65 });
854
+ }
855
+ }
856
+ // restNames tracks every rest-parameter name found, regardless of whether the
857
+ // scoped key was already in typeMap. This ensures the post-pass (below) processes
858
+ // all calls whose receiver matches a known rest binding — not just those whose
859
+ // typeMap entry was seeded in this iteration.
860
+ restNames.add(orpb.restName);
861
+ }
862
+ }
863
+ }
864
+ if (restNames.size === 0) continue;
865
+
866
+ for (const call of symbols.calls) {
867
+ // Only process calls whose receiver is a known rest-binding name.
868
+ if (!call.receiver || !restNames.has(call.receiver)) continue;
869
+
870
+ const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow);
871
+
872
+ // Resolve with the enriched typeMap. callerName is passed so
873
+ // resolveByMethodOrGlobal can look up the scoped key callee::restName (#1358).
874
+ // seenByPair deduplicates edges the native engine already emitted.
875
+ const { targets, importedFrom } = resolveCallTargets(
876
+ lookup,
877
+ call,
878
+ relPath,
879
+ importedNames,
880
+ typeMap as Map<string, unknown>,
881
+ caller.callerName,
882
+ );
883
+ for (const t of targets) {
884
+ const edgeKey = `${caller.id}|${t.id}`;
885
+ if (t.id !== caller.id && !seenByPair.has(edgeKey)) {
886
+ const conf =
887
+ computeConfidence(relPath, t.file, importedFrom ?? null) - PROPAGATION_HOP_PENALTY;
888
+ if (conf > 0) {
889
+ seenByPair.add(edgeKey);
890
+ allEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'points-to']);
891
+ }
892
+ }
893
+ }
894
+ }
895
+ }
896
+ }
897
+
898
+ /**
899
+ * Object.defineProperty accessor post-pass for the native call-edge path.
900
+ *
901
+ * When a function is registered as a getter/setter via
902
+ * `Object.defineProperty(obj, "bar", { get: getter })`, calls to `this.X()`
903
+ * inside `getter` need to resolve against `obj` (because `this === obj` when
904
+ * the accessor is invoked). The native Rust engine has no knowledge of
905
+ * `definePropertyReceivers`, so this JS post-pass adds the missing edges.
906
+ */
907
+ function buildDefinePropertyPostPass(
908
+ ctx: PipelineContext,
909
+ getNodeIdStmt: NodeIdStmt,
910
+ allEdgeRows: EdgeRowTuple[],
911
+ sharedLookup?: CallNodeLookup,
912
+ ): void {
913
+ const filesWithReceivers = [...ctx.fileSymbols].filter(
914
+ ([, symbols]) => symbols.definePropertyReceivers && symbols.definePropertyReceivers.size > 0,
915
+ );
916
+ if (filesWithReceivers.length === 0) return;
917
+
918
+ const seenByPair = new Set<string>();
919
+ for (const [srcId, tgtId] of allEdgeRows) {
920
+ seenByPair.add(`${srcId}|${tgtId}`);
921
+ }
922
+
923
+ const { barrelOnlyFiles, rootDir } = ctx;
924
+ const lookup = sharedLookup ?? makeContextLookup(ctx, getNodeIdStmt);
925
+
926
+ for (const [relPath, symbols] of filesWithReceivers) {
927
+ if (barrelOnlyFiles.has(relPath)) continue;
928
+ const fileNodeRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
929
+ if (!fileNodeRow) continue;
930
+
931
+ const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir);
932
+ const typeMap: Map<string, TypeMapEntry | string> = symbols.typeMap || new Map();
933
+ const definePropertyReceivers = symbols.definePropertyReceivers!;
934
+
935
+ for (const call of symbols.calls) {
936
+ if (call.receiver !== 'this') continue;
937
+
938
+ const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow);
939
+ if (!caller.callerName) continue;
940
+
941
+ const receiverVarName = definePropertyReceivers.get(caller.callerName);
942
+ if (!receiverVarName) continue;
943
+
944
+ // Only add edges the native engine missed (no direct target already).
945
+ const { targets: directTargets } = resolveCallTargets(
946
+ lookup,
947
+ call,
948
+ relPath,
949
+ importedNames,
950
+ typeMap as Map<string, unknown>,
951
+ caller.callerName,
952
+ );
953
+ if (directTargets.length > 0) continue;
954
+
955
+ // Resolve via receiver type
956
+ let targets: ReadonlyArray<{ id: number; file: string }> = [];
957
+ const typeEntry = typeMap.get(receiverVarName);
958
+ const typeName = typeEntry
959
+ ? typeof typeEntry === 'string'
960
+ ? typeEntry
961
+ : (typeEntry as { type?: string }).type
962
+ : null;
963
+ if (typeName) {
964
+ const qualifiedName = `${typeName}.${call.name}`;
965
+ targets = lookup.byNameAndFile(qualifiedName, relPath);
966
+ }
967
+ // Same-file fallback for plain object-literal methods
968
+ if (targets.length === 0) {
969
+ targets = lookup.byNameAndFile(call.name, relPath);
970
+ }
971
+
972
+ for (const t of targets) {
973
+ const edgeKey = `${caller.id}|${t.id}`;
974
+ if (t.id !== caller.id && !seenByPair.has(edgeKey)) {
975
+ const conf = computeConfidence(relPath, t.file, null);
976
+ if (conf > 0) {
977
+ seenByPair.add(edgeKey);
978
+ allEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'ts-native']);
979
+ }
980
+ }
981
+ }
982
+ }
983
+ }
984
+ }
985
+
986
+ /**
987
+ * Phase 8.5: CHA + RTA post-pass for the native call-edge path.
988
+ *
989
+ * The native Rust engine has no knowledge of the CHA context, so `this.method()`
990
+ * calls and interface method dispatches are not expanded to their concrete
991
+ * implementations. This JS post-pass runs after the native edges (and the pts
992
+ * post-pass) and adds only the CHA-resolved edges that the native engine missed.
993
+ *
994
+ * Like buildParamFlowPtsPostPass, it seeds seenByPair from the current allEdgeRows
995
+ * snapshot to avoid duplicating edges the native engine already produced.
996
+ */
997
+ function buildChaPostPass(
998
+ ctx: PipelineContext,
999
+ getNodeIdStmt: NodeIdStmt,
1000
+ allEdgeRows: EdgeRowTuple[],
1001
+ chaCtx: ChaContext,
1002
+ ): void {
1003
+ // Fast-exit when the CHA context is empty (no class hierarchy in the project)
1004
+ if (chaCtx.implementors.size === 0 && chaCtx.parents.size === 0) return;
1005
+
1006
+ // Seed only from 'calls' edges — import/extends/implements edges share (src,tgt) pairs
1007
+ // with real call edges at the file-node level and would cause false dedup if included.
1008
+ const seenByPair = new Set<string>();
1009
+ for (const row of allEdgeRows) {
1010
+ if (row[2] === 'calls') seenByPair.add(`${row[0]}|${row[1]}`);
1011
+ }
1012
+
1013
+ const { fileSymbols, barrelOnlyFiles } = ctx;
1014
+ const lookup = makeContextLookup(ctx, getNodeIdStmt);
1015
+
1016
+ for (const [relPath, symbols] of fileSymbols) {
1017
+ if (barrelOnlyFiles.has(relPath)) continue;
1018
+ const fileNodeRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
1019
+ if (!fileNodeRow) continue;
1020
+
1021
+ const typeMap: Map<string, TypeMapEntry | string> = symbols.typeMap || new Map();
1022
+
1023
+ for (const call of symbols.calls) {
1024
+ if (!call.receiver) continue;
1025
+ if (BUILTIN_RECEIVERS.has(call.receiver)) continue;
1026
+
1027
+ const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow);
1028
+ let chaTargets: ReadonlyArray<{ id: number; file: string }> = [];
1029
+
1030
+ if (call.receiver === 'this' || call.receiver === 'self' || call.receiver === 'super') {
1031
+ chaTargets = resolveThisDispatch(
1032
+ call.name,
1033
+ caller.callerName,
1034
+ call.receiver,
1035
+ chaCtx,
1036
+ lookup,
1037
+ );
1038
+ } else {
1039
+ const typeEntry = typeMap.get(call.receiver);
1040
+ const typeName = typeEntry
1041
+ ? typeof typeEntry === 'string'
1042
+ ? typeEntry
1043
+ : (typeEntry as { type?: string }).type
1044
+ : null;
1045
+ if (typeName) {
1046
+ chaTargets = resolveChaTargets(typeName, call.name, chaCtx, lookup);
1047
+ }
1048
+ }
1049
+
1050
+ for (const t of chaTargets) {
1051
+ const edgeKey = `${caller.id}|${t.id}`;
1052
+ if (t.id !== caller.id && !seenByPair.has(edgeKey)) {
1053
+ const conf = computeConfidence(relPath, t.file, null) - CHA_DISPATCH_PENALTY;
1054
+ if (conf > 0) {
1055
+ seenByPair.add(edgeKey);
1056
+ allEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'cha']);
1057
+ }
1058
+ }
1059
+ }
1060
+ }
447
1061
  }
448
1062
  }
449
1063
 
@@ -463,7 +1077,7 @@ function buildImportedNamesForNative(
463
1077
  const cleanName = name.replace(/^\*\s+as\s+/, '');
464
1078
  let targetFile = resolvedPath;
465
1079
  if (isBarrelFile(ctx, resolvedPath)) {
466
- const actual = resolveBarrelExport(ctx, resolvedPath, cleanName);
1080
+ const actual = resolveBarrelExportCached(ctx, resolvedPath, cleanName);
467
1081
  if (actual) targetFile = actual;
468
1082
  }
469
1083
  importedNames.push({ name: cleanName, file: targetFile });
@@ -484,8 +1098,10 @@ function buildCallEdgesJS(
484
1098
  ctx: PipelineContext,
485
1099
  getNodeIdStmt: NodeIdStmt,
486
1100
  allEdgeRows: EdgeRowTuple[],
1101
+ chaCtx?: ChaContext,
487
1102
  ): void {
488
1103
  const { fileSymbols, barrelOnlyFiles, rootDir } = ctx;
1104
+ const lookup = makeContextLookup(ctx, getNodeIdStmt);
489
1105
 
490
1106
  for (const [relPath, symbols] of fileSymbols) {
491
1107
  if (barrelOnlyFiles.has(relPath)) continue;
@@ -493,19 +1109,50 @@ function buildCallEdgesJS(
493
1109
  if (!fileNodeRow) continue;
494
1110
 
495
1111
  const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir);
496
- const typeMap: Map<string, TypeMapEntry | string> = symbols.typeMap || new Map();
1112
+ const typeMap: Map<string, TypeMapEntry | string> = new Map(
1113
+ symbols.typeMap instanceof Map ? symbols.typeMap : [],
1114
+ );
1115
+
1116
+ // Phase 8.3f: seed typeMap[callee::restName] = { type: argName } for each
1117
+ // object-destructuring rest parameter binding × call-site argument binding.
1118
+ // Keys are scoped so two functions with the same rest-param name in the same
1119
+ // file don't collide (#1358). When only one callee uses a given rest name,
1120
+ // also seed the unscoped key as a null-callerName fallback.
1121
+ if (symbols.objectRestParamBindings?.length && symbols.paramBindings?.length) {
1122
+ const restNameCallees = new Map<string, Set<string>>();
1123
+ for (const orpb of symbols.objectRestParamBindings) {
1124
+ if (!restNameCallees.has(orpb.restName)) restNameCallees.set(orpb.restName, new Set());
1125
+ restNameCallees.get(orpb.restName)!.add(orpb.callee);
1126
+ }
1127
+ for (const orpb of symbols.objectRestParamBindings) {
1128
+ for (const pb of symbols.paramBindings) {
1129
+ if (pb.callee === orpb.callee && pb.argIndex === orpb.argIndex) {
1130
+ const scopedKey = `${orpb.callee}::${orpb.restName}`;
1131
+ if (!typeMap.has(scopedKey)) {
1132
+ typeMap.set(scopedKey, { type: pb.argName, confidence: 0.65 });
1133
+ if (restNameCallees.get(orpb.restName)!.size === 1 && !typeMap.has(orpb.restName)) {
1134
+ typeMap.set(orpb.restName, { type: pb.argName, confidence: 0.65 });
1135
+ }
1136
+ }
1137
+ }
1138
+ }
1139
+ }
1140
+ }
1141
+
497
1142
  const seenCallEdges = new Set<string>();
1143
+ const ptsMap = buildPointsToMapForFile(symbols, importedNames);
498
1144
 
499
1145
  buildFileCallEdges(
500
- ctx,
501
1146
  relPath,
502
1147
  symbols,
503
1148
  fileNodeRow,
504
1149
  importedNames,
505
1150
  seenCallEdges,
506
- getNodeIdStmt,
1151
+ lookup,
507
1152
  allEdgeRows,
508
1153
  typeMap,
1154
+ ptsMap,
1155
+ chaCtx,
509
1156
  );
510
1157
  buildClassHierarchyEdges(ctx, relPath, symbols, allEdgeRows);
511
1158
  }
@@ -522,11 +1169,21 @@ function buildImportedNamesMap(
522
1169
  // (higher priority). Static imports represent direct bindings while dynamic
523
1170
  // imports often use aliased destructuring (`{ foo: bar } = await import(…)`).
524
1171
  // When both contribute the same name, the static binding is authoritative.
1172
+ //
1173
+ // Phase 8.4: trace through barrel files so that symbol names map to their
1174
+ // actual definition file, not the re-exporting barrel. Mirrors the tracing
1175
+ // already done in buildImportedNamesForNative (the native path).
1176
+ const traceBarrel = (resolvedPath: string, cleanName: string): string => {
1177
+ if (!isBarrelFile(ctx, resolvedPath)) return resolvedPath;
1178
+ const actual = resolveBarrelExportCached(ctx, resolvedPath, cleanName);
1179
+ return actual ?? resolvedPath;
1180
+ };
525
1181
  for (const imp of symbols.imports) {
526
1182
  if (!imp.dynamicImport) continue;
527
1183
  const resolvedPath = getResolved(ctx, path.join(rootDir, relPath), imp.source);
528
1184
  for (const name of imp.names) {
529
- importedNames.set(name.replace(/^\*\s+as\s+/, ''), resolvedPath);
1185
+ const cleanName = name.replace(/^\*\s+as\s+/, '');
1186
+ importedNames.set(cleanName, traceBarrel(resolvedPath, cleanName));
530
1187
  }
531
1188
  }
532
1189
  for (const imp of symbols.imports) {
@@ -534,145 +1191,371 @@ function buildImportedNamesMap(
534
1191
  const resolvedPath = getResolved(ctx, path.join(rootDir, relPath), imp.source);
535
1192
  for (const name of imp.names) {
536
1193
  const cleanName = name.replace(/^\*\s+as\s+/, '');
537
- importedNames.set(cleanName, resolvedPath);
1194
+ importedNames.set(cleanName, traceBarrel(resolvedPath, cleanName));
538
1195
  }
539
1196
  }
540
1197
  return importedNames;
541
1198
  }
542
1199
 
543
- function findCaller(
544
- call: Call,
545
- definitions: ReadonlyArray<{ name: string; kind: string; line: number; endLine?: number | null }>,
546
- relPath: string,
547
- getNodeIdStmt: NodeIdStmt,
548
- fileNodeRow: { id: number },
549
- ): { id: number } {
550
- let caller: { id: number } | null = null;
551
- let callerSpan = Infinity;
552
- for (const def of definitions) {
553
- if (def.line <= call.line) {
554
- const end = def.endLine || Infinity;
555
- if (call.line <= end) {
556
- const span = end - def.line;
557
- if (span < callerSpan) {
558
- const row = getNodeIdStmt.get(def.name, def.kind, relPath, def.line);
559
- if (row) {
560
- caller = row;
561
- callerSpan = span;
562
- }
563
- }
564
- }
565
- }
566
- }
567
- return caller || fileNodeRow;
1200
+ function makeContextLookup(ctx: PipelineContext, getNodeIdStmt: NodeIdStmt): CallNodeLookup {
1201
+ return {
1202
+ byNameAndFile: (name, file) => ctx.nodesByNameAndFile.get(`${name}|${file}`) ?? [],
1203
+ byName: (name) => ctx.nodesByName.get(name) ?? [],
1204
+ isBarrel: (file) => isBarrelFile(ctx, file),
1205
+ resolveBarrel: (barrelFile, symbolName) =>
1206
+ resolveBarrelExportCached(ctx, barrelFile, symbolName),
1207
+ nodeId: (name, kind, file, line) => getNodeIdStmt.get(name, kind, file, line),
1208
+ };
568
1209
  }
569
1210
 
570
- function resolveCallTargets(
571
- ctx: PipelineContext,
572
- call: Call,
573
- relPath: string,
1211
+ /**
1212
+ * Build a per-file points-to map for Phase 8.3 alias resolution.
1213
+ * Returns null fast when the file has no function-reference bindings.
1214
+ *
1215
+ * Only callable definitions (function/method) are seeded as concrete targets.
1216
+ * Class and interface names are intentionally excluded — aliasing a constructor
1217
+ * (`const Svc = MyService`) is an uncommon pattern that would require tracking
1218
+ * `new`-expression flows separately from the alias chain. That is left to Phase
1219
+ * 8.2 call-assignment propagation, which already handles constructor assignments.
1220
+ */
1221
+ function buildPointsToMapForFile(
1222
+ symbols: ExtractorOutput,
574
1223
  importedNames: Map<string, string>,
575
- typeMap: Map<string, TypeMapEntry | string>,
576
- ): { targets: NodeRow[]; importedFrom: string | undefined } {
577
- const importedFrom = importedNames.get(call.name);
578
- let targets: NodeRow[] | undefined;
579
-
580
- if (importedFrom) {
581
- targets = ctx.nodesByNameAndFile.get(`${call.name}|${importedFrom}`) || [];
582
- if (targets.length === 0 && isBarrelFile(ctx, importedFrom)) {
583
- const actualSource = resolveBarrelExport(ctx, importedFrom, call.name);
584
- if (actualSource) {
585
- targets = ctx.nodesByNameAndFile.get(`${call.name}|${actualSource}`) || [];
586
- }
587
- }
588
- }
589
-
590
- if (!targets || targets.length === 0) {
591
- targets = ctx.nodesByNameAndFile.get(`${call.name}|${relPath}`) || [];
592
- if (targets.length === 0) {
593
- targets = resolveByMethodOrGlobal(ctx, call, relPath, typeMap);
594
- }
595
- }
596
-
597
- if (targets.length > 1) {
598
- targets.sort((a, b) => {
599
- const confA = computeConfidence(relPath, a.file, importedFrom ?? null);
600
- const confB = computeConfidence(relPath, b.file, importedFrom ?? null);
601
- return confB - confA;
602
- });
1224
+ ): PointsToMap | null {
1225
+ const hasThisCallBindings = !!symbols.thisCallBindings?.length;
1226
+ if (
1227
+ !symbols.fnRefBindings?.length &&
1228
+ !symbols.paramBindings?.length &&
1229
+ !symbols.arrayElemBindings?.length &&
1230
+ !symbols.spreadArgBindings?.length &&
1231
+ !symbols.forOfBindings?.length &&
1232
+ !symbols.arrayCallbackBindings?.length &&
1233
+ !symbols.objectRestParamBindings?.length &&
1234
+ !symbols.objectPropBindings?.length &&
1235
+ !hasThisCallBindings
1236
+ )
1237
+ return null;
1238
+ const defNames = new Set(
1239
+ symbols.definitions
1240
+ .filter((d) => d.kind === 'function' || d.kind === 'method')
1241
+ .map((d) => d.name),
1242
+ );
1243
+ const definitionParams = buildDefinitionParamsMap(symbols.definitions);
1244
+
1245
+ // Convert thisCallBindings into scoped fnRefBindings: `fn::this → namedCtx`.
1246
+ // The scoped key `fn::this` is looked up when `this()` calls are resolved inside
1247
+ // function `fn` — caller.callerName='fn', call.name='this' scopedPtsKey='fn::this'.
1248
+ let allFnRefBindings: readonly FnRefBinding[] = symbols.fnRefBindings ?? [];
1249
+ if (hasThisCallBindings) {
1250
+ const extra: FnRefBinding[] = (symbols.thisCallBindings ?? []).map((b) => ({
1251
+ lhs: `${b.callee}::this`,
1252
+ rhs: b.thisArg,
1253
+ }));
1254
+ allFnRefBindings = [...allFnRefBindings, ...extra];
603
1255
  }
604
1256
 
605
- return { targets, importedFrom };
1257
+ return buildPointsToMap(
1258
+ allFnRefBindings,
1259
+ defNames,
1260
+ importedNames,
1261
+ symbols.paramBindings,
1262
+ definitionParams,
1263
+ symbols.arrayElemBindings,
1264
+ symbols.spreadArgBindings,
1265
+ symbols.forOfBindings,
1266
+ symbols.arrayCallbackBindings,
1267
+ symbols.objectRestParamBindings,
1268
+ symbols.objectPropBindings,
1269
+ );
606
1270
  }
607
1271
 
608
- function resolveByMethodOrGlobal(
609
- ctx: PipelineContext,
610
- call: Call,
611
- relPath: string,
612
- typeMap: Map<string, TypeMapEntry | string>,
613
- ): NodeRow[] {
614
- // Type-aware resolution: translate variable receiver to its declared type
615
- if (call.receiver && typeMap) {
616
- const typeEntry = typeMap.get(call.receiver);
617
- const typeName = typeEntry
618
- ? typeof typeEntry === 'string'
619
- ? typeEntry
620
- : typeEntry.type
621
- : null;
622
- if (typeName) {
623
- const qualifiedName = `${typeName}.${call.name}`;
624
- const typed = (ctx.nodesByName.get(qualifiedName) || []).filter((n) => n.kind === 'method');
625
- if (typed.length > 0) return typed;
1272
+ function buildDefinitionParamsMap(
1273
+ definitions: readonly Definition[],
1274
+ ): Map<string, readonly string[]> {
1275
+ const map = new Map<string, readonly string[]>();
1276
+ for (const def of definitions) {
1277
+ if ((def.kind === 'function' || def.kind === 'method') && def.children) {
1278
+ const params = def.children.filter((c) => c.kind === 'parameter').map((c) => c.name);
1279
+ if (params.length > 0) {
1280
+ if (map.has(def.name)) {
1281
+ // Two definitions share the same name (e.g. overloads, same-named method and
1282
+ // function, or conditional redeclaration). Keep the first entry — using the
1283
+ // wrong parameter list would map argIndex to the wrong parameter name.
1284
+ debug(
1285
+ `buildDefinitionParamsMap: duplicate def name "${def.name}" (kind=${def.kind}, line=${def.line}) — skipping; first entry kept`,
1286
+ );
1287
+ } else {
1288
+ map.set(def.name, params);
1289
+ }
1290
+ }
626
1291
  }
627
1292
  }
628
-
629
- if (
630
- !call.receiver ||
631
- call.receiver === 'this' ||
632
- call.receiver === 'self' ||
633
- call.receiver === 'super'
634
- ) {
635
- return (ctx.nodesByName.get(call.name) || []).filter(
636
- (n) => computeConfidence(relPath, n.file, null) >= 0.5,
637
- );
638
- }
639
- return [];
1293
+ return map;
640
1294
  }
641
1295
 
642
1296
  function buildFileCallEdges(
643
- ctx: PipelineContext,
644
1297
  relPath: string,
645
1298
  symbols: ExtractorOutput,
646
1299
  fileNodeRow: { id: number },
647
1300
  importedNames: Map<string, string>,
648
1301
  seenCallEdges: Set<string>,
649
- getNodeIdStmt: NodeIdStmt,
1302
+ lookup: CallNodeLookup,
650
1303
  allEdgeRows: EdgeRowTuple[],
651
1304
  typeMap: Map<string, TypeMapEntry | string>,
1305
+ ptsMap?: PointsToMap | null,
1306
+ chaCtx?: ChaContext,
652
1307
  ): void {
1308
+ // Tracks edges that were inserted by the pts fallback (edgeKey → allEdgeRows index).
1309
+ // Kept separate from seenCallEdges so that a subsequent direct-call edge for the same
1310
+ // caller→target pair can upgrade the confidence in-place rather than being silently
1311
+ // dropped by the dedup guard. Once upgraded, the key moves to seenCallEdges and is
1312
+ // no longer tracked here.
1313
+ const ptsEdgeRows = new Map<string, number>();
1314
+
1315
+ // Pre-compute the set of names that appear as lhs in fnRefBindings so that
1316
+ // case (c) of the pts gate below only fires for names that are genuine
1317
+ // bind/alias entries, not for every locally-defined function or import that
1318
+ // buildPointsToMap seeds with a self-pointing entry.
1319
+ const fnRefBindingLhs = new Set(symbols.fnRefBindings?.map((b) => b.lhs) ?? []);
1320
+
653
1321
  for (const call of symbols.calls) {
654
1322
  if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue;
655
1323
 
656
- const caller = findCaller(call, symbols.definitions, relPath, getNodeIdStmt, fileNodeRow);
1324
+ const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow);
657
1325
  const isDynamic: number = call.dynamic ? 1 : 0;
658
- const { targets, importedFrom } = resolveCallTargets(
659
- ctx,
1326
+ let { targets, importedFrom } = resolveCallTargets(
1327
+ lookup,
660
1328
  call,
661
1329
  relPath,
662
1330
  importedNames,
663
- typeMap,
1331
+ typeMap as Map<string, unknown>,
1332
+ caller.callerName,
664
1333
  );
665
1334
 
1335
+ // Same-class `this.method()` fallback: when the call receiver is `this` and
1336
+ // resolveCallTargets found nothing, derive the enclosing class name from the
1337
+ // caller (e.g. `Logger.info` → class prefix `Logger`) and retry with the
1338
+ // qualified method name `Logger._write`. This mirrors what the native Rust
1339
+ // engine does implicitly via its class-scoped symbol table.
1340
+ // NOTE: restricted to `this` only — `super.method()` targets a parent class,
1341
+ // not the enclosing class, so qualifying with the child class name would
1342
+ // produce a false edge when the child also defines a same-named method.
1343
+ if (targets.length === 0 && call.receiver === 'this' && caller.callerName != null) {
1344
+ const lastDot = caller.callerName.lastIndexOf('.');
1345
+ if (lastDot > 0) {
1346
+ const prevDot = caller.callerName.lastIndexOf('.', lastDot - 1);
1347
+ const className = caller.callerName.slice(prevDot + 1, lastDot);
1348
+ const qualifiedName = `${className}.${call.name}`;
1349
+ const qualified = lookup
1350
+ .byNameAndFile(qualifiedName, relPath)
1351
+ .filter((n) => n.kind === 'method');
1352
+ if (qualified.length > 0) {
1353
+ targets = qualified;
1354
+ }
1355
+ }
1356
+ }
1357
+
1358
+ // Same-class bare-call fallback: when a no-receiver call can't be resolved
1359
+ // globally, try the caller's own class as a qualifier. Handles C# static
1360
+ // sibling calls: `IsValidEmail()` inside `Validators.ValidateUser` resolves
1361
+ // to `Validators.IsValidEmail`. Skipped for JS/TS where bare calls are
1362
+ // module-scoped, not class-scoped.
1363
+ if (
1364
+ targets.length === 0 &&
1365
+ !call.receiver &&
1366
+ caller.callerName != null &&
1367
+ !isModuleScopedLanguage(relPath)
1368
+ ) {
1369
+ const lastDot = caller.callerName.lastIndexOf('.');
1370
+ if (lastDot > 0) {
1371
+ const prevDot = caller.callerName.lastIndexOf('.', lastDot - 1);
1372
+ const className = caller.callerName.slice(prevDot + 1, lastDot);
1373
+ const qualifiedName = `${className}.${call.name}`;
1374
+ const qualified = lookup
1375
+ .byNameAndFile(qualifiedName, relPath)
1376
+ .filter((n) => n.kind === 'method');
1377
+ if (qualified.length > 0) {
1378
+ targets = qualified;
1379
+ }
1380
+ }
1381
+ }
1382
+
1383
+ // Object.defineProperty accessor fallback: when a function is registered as
1384
+ // a getter/setter via `Object.defineProperty(obj, "bar", { get: getter })`,
1385
+ // calls to `this.X()` inside `getter` resolve against `obj` (this === obj
1386
+ // when the accessor is invoked). If the same-class fallback above found
1387
+ // nothing, try treating `obj` as the receiver and look up `obj.X` in the
1388
+ // typeMap, or fall back to a same-file lookup of any definition named X
1389
+ // that belongs to the object literal or its type.
1390
+ if (
1391
+ targets.length === 0 &&
1392
+ call.receiver === 'this' &&
1393
+ caller.callerName != null &&
1394
+ symbols.definePropertyReceivers
1395
+ ) {
1396
+ const receiverVarName = symbols.definePropertyReceivers.get(caller.callerName);
1397
+ if (receiverVarName) {
1398
+ // Try typeMap lookup for receiver.methodName
1399
+ const typeEntry = typeMap.get(receiverVarName);
1400
+ const typeName = typeEntry
1401
+ ? typeof typeEntry === 'string'
1402
+ ? typeEntry
1403
+ : (typeEntry as { type?: string }).type
1404
+ : null;
1405
+ if (typeName) {
1406
+ const qualifiedName = `${typeName}.${call.name}`;
1407
+ const qualified = lookup.byNameAndFile(qualifiedName, relPath);
1408
+ if (qualified.length > 0) {
1409
+ targets = [...qualified];
1410
+ }
1411
+ }
1412
+ // If still no targets, search for any definition named `call.name` in
1413
+ // the same file — handles plain object literals where the method isn't
1414
+ // qualified (e.g. `const obj = { baz() {} }` defines `baz` directly).
1415
+ // Note: this is intentionally broad — it matches any same-file definition
1416
+ // with the called name, not just members of the receiver object. This is
1417
+ // the same behaviour used by the native post-pass path (buildDefinePropertyPostPass).
1418
+ if (targets.length === 0) {
1419
+ const sameFile = lookup.byNameAndFile(call.name, relPath);
1420
+ if (sameFile.length > 0) {
1421
+ targets = [...sameFile];
1422
+ }
1423
+ }
1424
+ }
1425
+ }
1426
+
666
1427
  for (const t of targets) {
667
1428
  const edgeKey = `${caller.id}|${t.id}`;
668
- if (t.id !== caller.id && !seenCallEdges.has(edgeKey)) {
669
- seenCallEdges.add(edgeKey);
1429
+ if (t.id !== caller.id) {
670
1430
  const confidence = computeConfidence(relPath, t.file, importedFrom ?? null);
671
- allEdgeRows.push([caller.id, t.id, 'calls', confidence, isDynamic]);
1431
+ if (seenCallEdges.has(edgeKey)) continue;
1432
+ const ptsIdx = ptsEdgeRows.get(edgeKey);
1433
+ if (ptsIdx !== undefined) {
1434
+ // A pts-resolved edge already exists for this caller→target pair with a
1435
+ // penalised confidence. Upgrade it to the direct-call confidence in-place,
1436
+ // then promote to seenCallEdges so no further processing is needed.
1437
+ const ptsRow = allEdgeRows[ptsIdx];
1438
+ if (ptsRow) {
1439
+ ptsRow[3] = confidence;
1440
+ ptsRow[4] = isDynamic; // upgrade is_dynamic: direct call overrides the pts-alias dynamic flag
1441
+ ptsRow[5] = 'ts-native'; // promoted from pts to direct-call resolution
1442
+ }
1443
+ ptsEdgeRows.delete(edgeKey);
1444
+ seenCallEdges.add(edgeKey);
1445
+ } else {
1446
+ seenCallEdges.add(edgeKey);
1447
+ allEdgeRows.push([caller.id, t.id, 'calls', confidence, isDynamic, 'ts-native']);
1448
+ }
1449
+ }
1450
+ }
1451
+
1452
+ // Phase 8.3 / 8.3c / bind: points-to fallback for unresolved calls.
1453
+ // Fires for three cases:
1454
+ // (a) dynamic=true: alias calls emitted by extractCallbackReferenceCalls.
1455
+ // Looks up `call.name` directly (alias entries are flat-keyed).
1456
+ // (b) non-dynamic: parameter variable calls (fn() where fn is a param).
1457
+ // Looks up the scoped key `callerName::call.name` to avoid spurious
1458
+ // edges from same-named parameters across different functions.
1459
+ // (c) non-dynamic: module-level alias bindings — `f = fn.bind(ctx)` or
1460
+ // `const f = handler` — where pts('f') was seeded by fnRefBindings.
1461
+ // Checked against fnRefBindingLhs (the pre-computed set of lhs names from
1462
+ // fnRefBindings) rather than the full ptsMap, so case (c) only fires for
1463
+ // genuine bind/alias entries and never for self-seeded local definitions.
1464
+ // Confidence is penalised by one hop to reflect the extra indirection.
1465
+ //
1466
+ // Note: pts edges are added to ptsEdgeRows (not seenCallEdges) so that a later
1467
+ // direct call to the same target in the same function body can upgrade confidence
1468
+ // rather than being silently dropped by the dedup guard.
1469
+ const scopedPtsKey = caller.callerName != null ? `${caller.callerName}::${call.name}` : null;
1470
+ // Module-level calls (callerName === null) use the '<module>' sentinel emitted by
1471
+ // extractSpreadForOfWalk for top-level for-of loops. Look it up as a fallback so
1472
+ // that `for (const f of arr) { f(); }` at module scope resolves correctly.
1473
+ const modulePtsKey =
1474
+ caller.callerName === null && ptsMap?.has(`<module>::${call.name}`)
1475
+ ? `<module>::${call.name}`
1476
+ : null;
1477
+ const flatPtsKey =
1478
+ !call.dynamic && fnRefBindingLhs.has(call.name) && ptsMap?.has(call.name) ? call.name : null;
1479
+ if (
1480
+ targets.length === 0 &&
1481
+ !call.receiver &&
1482
+ ptsMap &&
1483
+ (call.dynamic ||
1484
+ (scopedPtsKey != null && ptsMap.has(scopedPtsKey)) ||
1485
+ modulePtsKey != null ||
1486
+ flatPtsKey != null)
1487
+ ) {
1488
+ const ptsLookupName = call.dynamic
1489
+ ? call.name
1490
+ : scopedPtsKey != null && ptsMap.has(scopedPtsKey)
1491
+ ? scopedPtsKey
1492
+ : modulePtsKey != null
1493
+ ? modulePtsKey
1494
+ : // flatPtsKey != null is guaranteed by the outer if condition: if neither
1495
+ // call.dynamic nor scopedPtsKey nor modulePtsKey matched, flatPtsKey must be non-null.
1496
+ flatPtsKey!;
1497
+ for (const alias of resolveViaPointsTo(ptsLookupName, ptsMap)) {
1498
+ // Resolve the concrete alias target. Only `name` is needed here — receiver
1499
+ // and line are not relevant for alias resolution (we are looking up the
1500
+ // aliased function by name, not dispatching a method call).
1501
+ const { targets: aliasTargets, importedFrom: aliasFrom } = resolveCallTargets(
1502
+ lookup,
1503
+ { name: alias },
1504
+ relPath,
1505
+ importedNames,
1506
+ typeMap as Map<string, unknown>,
1507
+ );
1508
+ for (const t of aliasTargets) {
1509
+ const edgeKey = `${caller.id}|${t.id}`;
1510
+ if (t.id !== caller.id && !seenCallEdges.has(edgeKey) && !ptsEdgeRows.has(edgeKey)) {
1511
+ const conf =
1512
+ computeConfidence(relPath, t.file, aliasFrom ?? null) - PROPAGATION_HOP_PENALTY;
1513
+ if (conf > 0) {
1514
+ ptsEdgeRows.set(edgeKey, allEdgeRows.length);
1515
+ allEdgeRows.push([caller.id, t.id, 'calls', conf, isDynamic, 'points-to']);
1516
+ }
1517
+ }
1518
+ }
1519
+ }
1520
+ }
1521
+
1522
+ // Phase 8.3f: pts fallback for receiver calls via object-rest param bindings.
1523
+ // Fires when `rest.prop()` is encountered and `rest` was seeded as `pts["rest.prop"]`
1524
+ // by the object-rest dispatch chain (ObjectRestParamBinding + paramBinding + ObjectPropBinding).
1525
+ if (
1526
+ targets.length === 0 &&
1527
+ call.receiver &&
1528
+ !BUILTIN_RECEIVERS.has(call.receiver) &&
1529
+ call.receiver !== 'this' &&
1530
+ call.receiver !== 'self' &&
1531
+ call.receiver !== 'super' &&
1532
+ ptsMap
1533
+ ) {
1534
+ const receiverKey = `${call.receiver}.${call.name}`;
1535
+ if (ptsMap.has(receiverKey)) {
1536
+ for (const alias of resolveViaPointsTo(receiverKey, ptsMap)) {
1537
+ const { targets: aliasTargets, importedFrom: aliasFrom } = resolveCallTargets(
1538
+ lookup,
1539
+ { name: alias },
1540
+ relPath,
1541
+ importedNames,
1542
+ typeMap as Map<string, unknown>,
1543
+ );
1544
+ for (const t of aliasTargets) {
1545
+ const edgeKey = `${caller.id}|${t.id}`;
1546
+ if (t.id !== caller.id && !seenCallEdges.has(edgeKey) && !ptsEdgeRows.has(edgeKey)) {
1547
+ const conf =
1548
+ computeConfidence(relPath, t.file, aliasFrom ?? null) - PROPAGATION_HOP_PENALTY;
1549
+ if (conf > 0) {
1550
+ ptsEdgeRows.set(edgeKey, allEdgeRows.length);
1551
+ allEdgeRows.push([caller.id, t.id, 'calls', conf, isDynamic, 'points-to']);
1552
+ }
1553
+ }
1554
+ }
1555
+ }
672
1556
  }
673
1557
  }
674
1558
 
675
- // Receiver edge
676
1559
  if (
677
1560
  call.receiver &&
678
1561
  !BUILTIN_RECEIVERS.has(call.receiver) &&
@@ -680,36 +1563,54 @@ function buildFileCallEdges(
680
1563
  call.receiver !== 'self' &&
681
1564
  call.receiver !== 'super'
682
1565
  ) {
683
- buildReceiverEdge(ctx, call, caller, relPath, seenCallEdges, allEdgeRows, typeMap);
1566
+ const recv = resolveReceiverEdge(
1567
+ lookup,
1568
+ { name: call.name, receiver: call.receiver },
1569
+ caller,
1570
+ relPath,
1571
+ typeMap as Map<string, unknown>,
1572
+ seenCallEdges,
1573
+ );
1574
+ if (recv) {
1575
+ allEdgeRows.push([recv.callerId, recv.receiverId, 'receiver', recv.confidence, 0, null]);
1576
+ }
684
1577
  }
685
- }
686
- }
687
1578
 
688
- function buildReceiverEdge(
689
- ctx: PipelineContext,
690
- call: Call,
691
- caller: { id: number },
692
- relPath: string,
693
- seenCallEdges: Set<string>,
694
- allEdgeRows: EdgeRowTuple[],
695
- typeMap: Map<string, TypeMapEntry | string>,
696
- ): void {
697
- const receiverKinds = new Set(['class', 'struct', 'interface', 'type', 'module']);
698
- const typeEntry = typeMap?.get(call.receiver!);
699
- const typeName = typeEntry ? (typeof typeEntry === 'string' ? typeEntry : typeEntry.type) : null;
700
- const typeConfidence = typeEntry && typeof typeEntry === 'object' ? typeEntry.confidence : null;
701
- const effectiveReceiver = typeName || call.receiver!;
702
- const samefile = ctx.nodesByNameAndFile.get(`${effectiveReceiver}|${relPath}`) || [];
703
- const candidates = samefile.length > 0 ? samefile : ctx.nodesByName.get(effectiveReceiver) || [];
704
- const receiverNodes = candidates.filter((n) => receiverKinds.has(n.kind));
705
- if (receiverNodes.length > 0 && caller) {
706
- const recvTarget = receiverNodes[0]!;
707
- const recvKey = `recv|${caller.id}|${recvTarget.id}`;
708
- if (!seenCallEdges.has(recvKey)) {
709
- seenCallEdges.add(recvKey);
710
- // Use type source confidence when available, otherwise 0.7 for untyped receiver
711
- const confidence = typeConfidence ?? (typeName ? 0.9 : 0.7);
712
- allEdgeRows.push([caller.id, recvTarget.id, 'receiver', confidence, 0]);
1579
+ // Phase 8.5: CHA + RTA dispatch expansion.
1580
+ // For `this`/`self`/`super` calls: resolve through the class hierarchy instead
1581
+ // of relying solely on global name matching.
1582
+ // For typed receiver calls: expand to all instantiated concrete implementations.
1583
+ if (chaCtx && call.receiver) {
1584
+ let chaTargets: ReadonlyArray<{ id: number; file: string }> = [];
1585
+ if (call.receiver === 'this' || call.receiver === 'self' || call.receiver === 'super') {
1586
+ chaTargets = resolveThisDispatch(
1587
+ call.name,
1588
+ caller.callerName,
1589
+ call.receiver,
1590
+ chaCtx,
1591
+ lookup,
1592
+ );
1593
+ } else if (!BUILTIN_RECEIVERS.has(call.receiver)) {
1594
+ const typeEntry = typeMap.get(call.receiver);
1595
+ const typeName = typeEntry
1596
+ ? typeof typeEntry === 'string'
1597
+ ? typeEntry
1598
+ : (typeEntry as { type?: string }).type
1599
+ : null;
1600
+ if (typeName) {
1601
+ chaTargets = resolveChaTargets(typeName, call.name, chaCtx, lookup);
1602
+ }
1603
+ }
1604
+ for (const t of chaTargets) {
1605
+ const edgeKey = `${caller.id}|${t.id}`;
1606
+ if (t.id !== caller.id && !seenCallEdges.has(edgeKey) && !ptsEdgeRows.has(edgeKey)) {
1607
+ const conf = computeConfidence(relPath, t.file, null) - CHA_DISPATCH_PENALTY;
1608
+ if (conf > 0) {
1609
+ seenCallEdges.add(edgeKey);
1610
+ allEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'cha']);
1611
+ }
1612
+ }
1613
+ }
713
1614
  }
714
1615
  }
715
1616
  }
@@ -736,7 +1637,7 @@ function buildClassHierarchyEdges(
736
1637
  );
737
1638
  if (sourceRow) {
738
1639
  for (const t of targetRows) {
739
- allEdgeRows.push([sourceRow.id, t.id, 'extends', 1.0, 0]);
1640
+ allEdgeRows.push([sourceRow.id, t.id, 'extends', 1.0, 0, null]);
740
1641
  }
741
1642
  }
742
1643
  }
@@ -750,13 +1651,56 @@ function buildClassHierarchyEdges(
750
1651
  );
751
1652
  if (sourceRow) {
752
1653
  for (const t of targetRows) {
753
- allEdgeRows.push([sourceRow.id, t.id, 'implements', 1.0, 0]);
1654
+ allEdgeRows.push([sourceRow.id, t.id, 'implements', 1.0, 0, null]);
754
1655
  }
755
1656
  }
756
1657
  }
757
1658
  }
758
1659
  }
759
1660
 
1661
+ // ── Native bulk-insert technique back-fill ──────────────────────────────
1662
+
1663
+ /**
1664
+ * After native bulkInsertEdges (which does not write the technique column),
1665
+ * apply technique values from the in-memory row array back to the DB.
1666
+ *
1667
+ * Rows with an explicit technique get a targeted UPDATE by (source_id, target_id).
1668
+ * The catch-all 'ts-native' tag is scoped to only the source_ids present in this
1669
+ * batch — this prevents mis-tagging pre-migration NULL-technique edges from
1670
+ * unchanged files that were never purged and re-inserted.
1671
+ */
1672
+ function applyEdgeTechniquesAfterNativeInsert(
1673
+ db: BetterSqlite3Database,
1674
+ rows: EdgeRowTuple[],
1675
+ ): void {
1676
+ const callRows = rows.filter((r) => r[2] === 'calls');
1677
+ if (callRows.length === 0) return;
1678
+
1679
+ const taggedRows = callRows.filter((r) => r[5] != null);
1680
+ // Collect distinct source IDs for this batch so the catch-all UPDATE is scoped
1681
+ // to edges inserted in the current run, not the entire table.
1682
+ const sourceIds = [...new Set(callRows.map((r) => r[0]))];
1683
+ // Chunk to stay within SQLite's SQLITE_LIMIT_VARIABLE_NUMBER (999 on older builds).
1684
+ const CHUNK_SIZE = 500;
1685
+
1686
+ const tx = db.transaction(() => {
1687
+ if (taggedRows.length > 0) {
1688
+ const stmt = db.prepare(
1689
+ "UPDATE edges SET technique = ? WHERE kind = 'calls' AND source_id = ? AND target_id = ? AND technique IS NULL",
1690
+ );
1691
+ for (const r of taggedRows) stmt.run(r[5], r[0], r[1]);
1692
+ }
1693
+ for (let i = 0; i < sourceIds.length; i += CHUNK_SIZE) {
1694
+ const chunk = sourceIds.slice(i, i + CHUNK_SIZE);
1695
+ const placeholders = chunk.map(() => '?').join(',');
1696
+ db.prepare(
1697
+ `UPDATE edges SET technique = 'ts-native' WHERE kind = 'calls' AND technique IS NULL AND source_id IN (${placeholders})`,
1698
+ ).run(...chunk);
1699
+ }
1700
+ });
1701
+ tx();
1702
+ }
1703
+
760
1704
  // ── Reverse-dep edge reconnection (#932, #933) ─────────────────────────
761
1705
 
762
1706
  /**
@@ -789,6 +1733,7 @@ function reconnectReverseDepEdges(ctx: PipelineContext): void {
789
1733
  saved.edgeKind,
790
1734
  saved.confidence,
791
1735
  saved.dynamic,
1736
+ saved.technique,
792
1737
  ]);
793
1738
  } else {
794
1739
  // Target was removed or renamed in the changed file — edge is stale
@@ -808,6 +1753,8 @@ function reconnectReverseDepEdges(ctx: PipelineContext): void {
808
1753
  const ok = ctx.nativeDb.bulkInsertEdges(nativeEdges);
809
1754
  if (!ok) {
810
1755
  batchInsertEdges(db, reconnectedRows);
1756
+ } else {
1757
+ applyEdgeTechniquesAfterNativeInsert(db, reconnectedRows);
811
1758
  }
812
1759
  } else {
813
1760
  batchInsertEdges(db, reconnectedRows);
@@ -907,8 +1854,25 @@ export async function buildEdges(ctx: PipelineContext): Promise<void> {
907
1854
  addLazyFallback(ctx, scopedLoad);
908
1855
 
909
1856
  const t0 = performance.now();
1857
+
1858
+ // Enrich typeMap for .ts/.tsx files using the TypeScript compiler API.
1859
+ // Runs before call-edge construction so the accurate types are available
1860
+ // for method-call resolution. Gated on config so users can opt out.
1861
+ if (ctx.config.build.typescriptResolver) {
1862
+ await enrichTypeMapWithTsc(ctx.rootDir, ctx.fileSymbols);
1863
+ }
1864
+
910
1865
  const native = engineName === 'native' ? loadNative() : null;
911
1866
 
1867
+ // Phase 8.2: Augment typeMaps with cross-file return-type propagation before
1868
+ // the transaction opens. This is pure in-memory mutation (no DB I/O) and must
1869
+ // run outside the transaction to avoid leaving ctx.fileSymbols in a partial
1870
+ // state if the transaction rolls back unexpectedly.
1871
+ propagateReturnTypesAcrossFiles(ctx.fileSymbols, ctx, ctx.rootDir);
1872
+ // Phase 8.5: Build CHA context after propagation so typeMap confidence values
1873
+ // (used for RTA seeding) reflect any cross-file propagated types.
1874
+ const chaCtx = buildChaContext(ctx.fileSymbols);
1875
+
912
1876
  // Phase 1: Compute edges inside a better-sqlite3 transaction.
913
1877
  // Barrel-edge deletion lives here so that the JS path (which also inserts
914
1878
  // edges in this transaction) keeps deletion + insertion atomic.
@@ -957,8 +1921,35 @@ export async function buildEdges(ctx: PipelineContext): Promise<void> {
957
1921
  (ctx.isFullBuild || ctx.fileSymbols.size > ctx.config.build.smallFilesThreshold);
958
1922
  if (useNativeCallEdges) {
959
1923
  buildCallEdgesNative(ctx, getNodeIdStmt, allEdgeRows, allNodesBefore, native!);
1924
+ // Build the shared lookup once — both pts post-passes use it, avoiding
1925
+ // redundant construction of the same context closure.
1926
+ const sharedLookup = makeContextLookup(ctx, getNodeIdStmt);
1927
+ // Phase 8.3c post-pass: augment native call edges with parameter-flow pts
1928
+ // edges. The native Rust engine has no knowledge of paramBindings, so any
1929
+ // `fn()` call inside a higher-order function would be missed. This JS pass
1930
+ // runs on top of the native edges and adds only the pts-resolved edges that
1931
+ // the native engine could not produce.
1932
+ buildParamFlowPtsPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
1933
+ // bind/alias post-pass: augment native call edges with fnRefBindings-seeded
1934
+ // pts edges. The native Rust engine has no knowledge of JS fnRefBindings
1935
+ // (e.g. `const f = fn.bind(ctx)`), so calls to bind-created aliases are
1936
+ // not resolved to their original function on the native path.
1937
+ buildFnRefBindingsPtsPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
1938
+ // this-rebinding post-pass: resolve `this()` calls inside functions that
1939
+ // were invoked via `.call(namedCtx, ...)` / `.apply(namedCtx, ...)`.
1940
+ buildThisCallBindingsPtsPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
1941
+ // Phase 8.3f post-pass: augment native call edges with object rest-param
1942
+ // receiver resolution — typeMap[restName] → argName → typeMap[argName.method].
1943
+ buildObjectRestParamPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
1944
+ // Object.defineProperty accessor post-pass: resolve this-dispatch inside
1945
+ // getter/setter functions registered via Object.defineProperty.
1946
+ buildDefinePropertyPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
1947
+ // Phase 8.5 post-pass: augment native call edges with CHA-resolved dispatch.
1948
+ // The native Rust engine has no knowledge of the CHA context, so this/self
1949
+ // calls and interface dispatch are not expanded to concrete implementations.
1950
+ buildChaPostPass(ctx, getNodeIdStmt, allEdgeRows, chaCtx);
960
1951
  } else {
961
- buildCallEdgesJS(ctx, getNodeIdStmt, allEdgeRows);
1952
+ buildCallEdgesJS(ctx, getNodeIdStmt, allEdgeRows, chaCtx);
962
1953
  }
963
1954
 
964
1955
  // When using native edge insert, skip JS insert here — do it after tx commits.
@@ -985,6 +1976,8 @@ export async function buildEdges(ctx: PipelineContext): Promise<void> {
985
1976
  if (!ok) {
986
1977
  debug('Native bulkInsertEdges failed — falling back to JS batchInsertEdges');
987
1978
  batchInsertEdges(ctx.db, allEdgeRows);
1979
+ } else {
1980
+ applyEdgeTechniquesAfterNativeInsert(ctx.db, allEdgeRows);
988
1981
  }
989
1982
  }
990
1983
 
@@ -997,5 +1990,12 @@ export async function buildEdges(ctx: PipelineContext): Promise<void> {
997
1990
  reconnectReverseDepEdges(ctx);
998
1991
  }
999
1992
 
1993
+ // Phase 4: CHA post-pass — expand virtual-dispatch edges for class hierarchies
1994
+ // and interface implementations. Runs after all call + hierarchy edges are
1995
+ // committed so the DB is consistent.
1996
+ // Note: the native orchestrator success path runs this independently in
1997
+ // tryNativeOrchestrator; this phase covers the WASM and native-fallback paths.
1998
+ runChaPostPass(db);
1999
+
1000
2000
  ctx.timing.edgesMs = performance.now() - t0;
1001
2001
  }