@@ -552,6 +552,7 @@ function buildParamFlowPtsPostPass(
552552 ctx : PipelineContext ,
553553 getNodeIdStmt : NodeIdStmt ,
554554 allEdgeRows : EdgeRowTuple [ ] ,
555+ sharedLookup ?: CallNodeLookup ,
555556) : void {
556557 // Only process files that actually have paramBindings (avoid useless work).
557558 const filesWithParams = [ ...ctx . fileSymbols ] . filter (
@@ -567,7 +568,7 @@ function buildParamFlowPtsPostPass(
567568 }
568569
569570 const { barrelOnlyFiles, rootDir } = ctx ;
570- const lookup = makeContextLookup ( ctx , getNodeIdStmt ) ;
571+ const lookup = sharedLookup ?? makeContextLookup ( ctx , getNodeIdStmt ) ;
571572
572573 for ( const [ relPath , symbols ] of filesWithParams ) {
573574 if ( barrelOnlyFiles . has ( relPath ) ) continue ;
@@ -620,6 +621,94 @@ function buildParamFlowPtsPostPass(
620621 }
621622}
622623
624+ /**
625+ * bind/alias pts post-pass for the native call-edge path.
626+ *
627+ * The native Rust engine has no knowledge of JS-layer fnRefBindings (e.g.
628+ * `const f = fn.bind(ctx)`), so calls to bind-created aliases are not resolved
629+ * to their original function on the native path. This JS post-pass runs after
630+ * the native edge pass and adds only the fnRefBindings-seeded pts edges that the
631+ * native engine missed.
632+ *
633+ * Uses the same seenByPair dedup guard as buildParamFlowPtsPostPass to avoid
634+ * duplicating edges already emitted by the native engine.
635+ */
636+ function buildFnRefBindingsPtsPostPass (
637+ ctx : PipelineContext ,
638+ getNodeIdStmt : NodeIdStmt ,
639+ allEdgeRows : EdgeRowTuple [ ] ,
640+ sharedLookup ?: CallNodeLookup ,
641+ ) : void {
642+ // Only process files that actually have fnRefBindings.
643+ const filesWithBindings = [ ...ctx . fileSymbols ] . filter (
644+ ( [ , symbols ] ) => symbols . fnRefBindings && symbols . fnRefBindings . length > 0 ,
645+ ) ;
646+ if ( filesWithBindings . length === 0 ) return ;
647+
648+ // Seed seenByPair from the existing rows so we don't duplicate native edges.
649+ const seenByPair = new Set < string > ( ) ;
650+ for ( const [ srcId , tgtId ] of allEdgeRows ) {
651+ seenByPair . add ( `${ srcId } |${ tgtId } ` ) ;
652+ }
653+
654+ const { barrelOnlyFiles, rootDir } = ctx ;
655+ const lookup = sharedLookup ?? makeContextLookup ( ctx , getNodeIdStmt ) ;
656+
657+ for ( const [ relPath , symbols ] of filesWithBindings ) {
658+ if ( barrelOnlyFiles . has ( relPath ) ) continue ;
659+ const fileNodeRow = getNodeIdStmt . get ( relPath , 'file' , relPath , 0 ) ;
660+ if ( ! fileNodeRow ) continue ;
661+
662+ const importedNames = buildImportedNamesMap ( ctx , relPath , symbols , rootDir ) ;
663+ const typeMap : Map < string , TypeMapEntry | string > = symbols . typeMap || new Map ( ) ;
664+ const ptsMap = buildPointsToMapForFile ( symbols , importedNames ) ;
665+ if ( ! ptsMap ) continue ;
666+
667+ // Only resolve calls whose name is an lhs in fnRefBindings — the same
668+ // narrowed guard used in buildFileCallEdges case (c).
669+ const fnRefBindingLhs = new Set ( symbols . fnRefBindings ! . map ( ( b ) => b . lhs ) ) ;
670+
671+ for ( const call of symbols . calls ) {
672+ if ( call . receiver || call . dynamic ) continue ; // bind aliases are flat-keyed, never dynamic
673+ if ( ! fnRefBindingLhs . has ( call . name ) ) continue ;
674+ if ( ! ptsMap . has ( call . name ) ) continue ;
675+
676+ const caller = findCaller ( lookup , call , symbols . definitions , relPath , fileNodeRow ) ;
677+
678+ // Only resolve calls that had no direct targets (same guard as buildFileCallEdges).
679+ const { targets } = resolveCallTargets (
680+ lookup ,
681+ call ,
682+ relPath ,
683+ importedNames ,
684+ typeMap as Map < string , unknown > ,
685+ ) ;
686+ if ( targets . length > 0 ) continue ;
687+
688+ for ( const alias of resolveViaPointsTo ( call . name , ptsMap ) ) {
689+ const { targets : aliasTargets , importedFrom : aliasFrom } = resolveCallTargets (
690+ lookup ,
691+ { name : alias } ,
692+ relPath ,
693+ importedNames ,
694+ typeMap as Map < string , unknown > ,
695+ ) ;
696+ for ( const t of aliasTargets ) {
697+ const edgeKey = `${ caller . id } |${ t . id } ` ;
698+ if ( t . id !== caller . id && ! seenByPair . has ( edgeKey ) ) {
699+ const conf =
700+ computeConfidence ( relPath , t . file , aliasFrom ?? null ) - PROPAGATION_HOP_PENALTY ;
701+ if ( conf > 0 ) {
702+ seenByPair . add ( edgeKey ) ;
703+ allEdgeRows . push ( [ caller . id , t . id , 'calls' , conf , 0 , 'points-to' ] ) ;
704+ }
705+ }
706+ }
707+ }
708+ }
709+ }
710+ }
711+
623712/**
624713 * Phase 8.5: CHA + RTA post-pass for the native call-edge path.
625714 *
@@ -889,6 +978,12 @@ function buildFileCallEdges(
889978 // no longer tracked here.
890979 const ptsEdgeRows = new Map < string , number > ( ) ;
891980
981+ // Pre-compute the set of names that appear as lhs in fnRefBindings so that
982+ // case (c) of the pts gate below only fires for names that are genuine
983+ // bind/alias entries, not for every locally-defined function or import that
984+ // buildPointsToMap seeds with a self-pointing entry.
985+ const fnRefBindingLhs = new Set ( symbols . fnRefBindings ?. map ( ( b ) => b . lhs ) ?? [ ] ) ;
986+
892987 for ( const call of symbols . calls ) {
893988 if ( call . receiver && BUILTIN_RECEIVERS . has ( call . receiver ) ) continue ;
894989
@@ -950,26 +1045,39 @@ function buildFileCallEdges(
9501045 }
9511046 }
9521047
953- // Phase 8.3 / 8.3c: points-to fallback for unresolved calls.
954- // Fires for two cases:
1048+ // Phase 8.3 / 8.3c / bind : points-to fallback for unresolved calls.
1049+ // Fires for three cases:
9551050 // (a) dynamic=true: alias calls emitted by extractCallbackReferenceCalls.
9561051 // Looks up `call.name` directly (alias entries are flat-keyed).
9571052 // (b) non-dynamic: parameter variable calls (fn() where fn is a param).
9581053 // Looks up the scoped key `callerName::call.name` to avoid spurious
9591054 // edges from same-named parameters across different functions.
1055+ // (c) non-dynamic: module-level alias bindings — `f = fn.bind(ctx)` or
1056+ // `const f = handler` — where pts('f') was seeded by fnRefBindings.
1057+ // Checked against fnRefBindingLhs (the pre-computed set of lhs names from
1058+ // fnRefBindings) rather than the full ptsMap, so case (c) only fires for
1059+ // genuine bind/alias entries and never for self-seeded local definitions.
9601060 // Confidence is penalised by one hop to reflect the extra indirection.
9611061 //
9621062 // Note: pts edges are added to ptsEdgeRows (not seenCallEdges) so that a later
9631063 // direct call to the same target in the same function body can upgrade confidence
9641064 // rather than being silently dropped by the dedup guard.
9651065 const scopedPtsKey = caller . callerName != null ? `${ caller . callerName } ::${ call . name } ` : null ;
1066+ const flatPtsKey =
1067+ ! call . dynamic && fnRefBindingLhs . has ( call . name ) && ptsMap ?. has ( call . name ) ? call . name : null ;
9661068 if (
9671069 targets . length === 0 &&
9681070 ! call . receiver &&
9691071 ptsMap &&
970- ( call . dynamic || ( scopedPtsKey != null && ptsMap . has ( scopedPtsKey ) ) )
1072+ ( call . dynamic || ( scopedPtsKey != null && ptsMap . has ( scopedPtsKey ) ) || flatPtsKey != null )
9711073 ) {
972- const ptsLookupName = call . dynamic ? call . name : ( scopedPtsKey ?? call . name ) ;
1074+ const ptsLookupName = call . dynamic
1075+ ? call . name
1076+ : scopedPtsKey != null && ptsMap . has ( scopedPtsKey )
1077+ ? scopedPtsKey
1078+ : // flatPtsKey != null is guaranteed by the outer if condition: if neither
1079+ // call.dynamic nor scopedPtsKey matched, flatPtsKey != null must be true.
1080+ flatPtsKey ! ;
9731081 for ( const alias of resolveViaPointsTo ( ptsLookupName , ptsMap ) ) {
9741082 // Resolve the concrete alias target. Only `name` is needed here — receiver
9751083 // and line are not relevant for alias resolution (we are looking up the
@@ -1360,12 +1468,20 @@ export async function buildEdges(ctx: PipelineContext): Promise<void> {
13601468 ( ctx . isFullBuild || ctx . fileSymbols . size > ctx . config . build . smallFilesThreshold ) ;
13611469 if ( useNativeCallEdges ) {
13621470 buildCallEdgesNative ( ctx , getNodeIdStmt , allEdgeRows , allNodesBefore , native ! ) ;
1471+ // Build the shared lookup once — both pts post-passes use it, avoiding
1472+ // redundant construction of the same context closure.
1473+ const sharedLookup = makeContextLookup ( ctx , getNodeIdStmt ) ;
13631474 // Phase 8.3c post-pass: augment native call edges with parameter-flow pts
13641475 // edges. The native Rust engine has no knowledge of paramBindings, so any
13651476 // `fn()` call inside a higher-order function would be missed. This JS pass
13661477 // runs on top of the native edges and adds only the pts-resolved edges that
13671478 // the native engine could not produce.
1368- buildParamFlowPtsPostPass ( ctx , getNodeIdStmt , allEdgeRows ) ;
1479+ buildParamFlowPtsPostPass ( ctx , getNodeIdStmt , allEdgeRows , sharedLookup ) ;
1480+ // bind/alias post-pass: augment native call edges with fnRefBindings-seeded
1481+ // pts edges. The native Rust engine has no knowledge of JS fnRefBindings
1482+ // (e.g. `const f = fn.bind(ctx)`), so calls to bind-created aliases are
1483+ // not resolved to their original function on the native path.
1484+ buildFnRefBindingsPtsPostPass ( ctx , getNodeIdStmt , allEdgeRows , sharedLookup ) ;
13691485 // Phase 8.5 post-pass: augment native call edges with CHA-resolved dispatch.
13701486 // The native Rust engine has no knowledge of the CHA context, so this/self
13711487 // calls and interface dispatch are not expanded to concrete implementations.
0 commit comments