Skip to content

Commit a233110

Browse files
authored
feat(resolver): track Function.bind/call/apply for receiver-typed resolution (JS) (#1330)
* feat(resolver): track Function.bind/call/apply for receiver-typed resolution (JS) - bind: var f = fn.bind(ctx) seeds fnRefBinding {lhs:f, rhs:fn} so pts(f) ⊇ pts(fn); direct calls f() then resolve to fn via points-to. - call/apply: f.call(ctx, args) and f.apply(ctx, args) already extract f as the callee via extractMemberExprCallInfo; no new extraction needed. - pts gate: extend build-edges to also check the flat unscoped pts key for module-level alias bindings (case c) — safe because resolveViaPointsTo filters self-references, so self-seeded definitions never produce spurious edges. Adds bind-call-apply.js fixture; JS benchmark stays at 100% precision. Closes #1318 docs check acknowledged * fix(resolver): narrow case (c) pts gate and add native post-pass for bind aliases - Case (c) flatPtsKey guard now checks fnRefBindingLhs (lhs names from fnRefBindings) instead of the full ptsMap, preventing spurious edges when a locally-defined function shares a name with a bind/alias lhs. - Remove dead `?? call.name` fallback in the ptsLookupName ternary; use flatPtsKey! (non-null assertion) which is guaranteed by the outer if guard. - Add buildFnRefBindingsPtsPostPass for the native engine path so that bind-created aliases resolve on --engine native with the same correctness as the WASM/JS path. * fix(resolver): share context lookup between post-passes and document bind scope
1 parent 16a1182 commit a233110

4 files changed

Lines changed: 183 additions & 8 deletions

File tree

src/domain/graph/builder/stages/build-edges.ts

Lines changed: 122 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

src/extractors/javascript.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1454,8 +1454,7 @@ function handleVarDeclaratorTypeMap(
14541454

14551455
// Phase 8.3: record function-reference bindings before any type-analysis early returns.
14561456
// Captures `const fn = handler` (identifier) and `const fn = obj.method` (member_expression).
1457-
// call_expression and new_expression are intentionally excluded — those are handled by
1458-
// Phase 8.2 callAssignments and the constructor type-map respectively.
1457+
// Also handles `const f = fn.bind(ctx)` — bind returns a new function aliasing fn.
14591458
if (fnRefBindings && valueN) {
14601459
if (valueN.type === 'identifier' && !BUILTIN_GLOBALS.has(valueN.text)) {
14611460
fnRefBindings.push({ lhs: nameN.text, rhs: valueN.text });
@@ -1473,6 +1472,21 @@ function handleVarDeclaratorTypeMap(
14731472
) {
14741473
fnRefBindings.push({ lhs: nameN.text, rhs: prop.text, rhsReceiver: obj.text });
14751474
}
1475+
} else if (valueN.type === 'call_expression') {
1476+
// `const f = fn.bind(ctx)` — bind returns a bound copy of fn; track f → fn so
1477+
// pts(f) ⊇ pts(fn) and subsequent `f(args)` calls resolve to fn.
1478+
// Note: only flat-identifier binds (fn.bind) are tracked here; method-receiver
1479+
// binds like `obj.method.bind(ctx)` are not captured (boundFn must be an identifier).
1480+
const callFn = valueN.childForFieldName('function');
1481+
if (callFn?.type === 'member_expression') {
1482+
const bindProp = callFn.childForFieldName('property');
1483+
if (bindProp?.text === 'bind') {
1484+
const boundFn = callFn.childForFieldName('object');
1485+
if (boundFn?.type === 'identifier' && !BUILTIN_GLOBALS.has(boundFn.text)) {
1486+
fnRefBindings.push({ lhs: nameN.text, rhs: boundFn.text });
1487+
}
1488+
}
1489+
}
14761490
}
14771491
}
14781492

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Patterns for Function.prototype.bind / .call / .apply resolution.
2+
3+
function greet(greeting) {
4+
return greeting + ' ' + this.name;
5+
}
6+
7+
var user = { name: 'Alice' };
8+
9+
// bind: var f = fn.bind(ctx) — f() should resolve to fn()
10+
var greetUser = greet.bind(user);
11+
12+
export function runBind() {
13+
return greetUser('Hello');
14+
}
15+
16+
// call: fn.call(ctx, args) — resolved as a direct call to fn
17+
export function runCall() {
18+
return greet.call(user, 'Hi');
19+
}
20+
21+
// apply: fn.apply(ctx, argsArray) — resolved as a direct call to fn
22+
export function runApply() {
23+
return greet.apply(user, ['Hey']);
24+
}

tests/benchmarks/resolution/fixtures/javascript/expected-edges.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,27 @@
129129
"mode": "constructor",
130130
"notes": "new UserService() — class instantiation tracked as consumption"
131131
},
132+
{
133+
"source": { "name": "runBind", "file": "bind-call-apply.js" },
134+
"target": { "name": "greet", "file": "bind-call-apply.js" },
135+
"kind": "calls",
136+
"mode": "points-to",
137+
"notes": "greetUser() — greetUser = greet.bind(user), pts tracks greetUser → greet"
138+
},
139+
{
140+
"source": { "name": "runCall", "file": "bind-call-apply.js" },
141+
"target": { "name": "greet", "file": "bind-call-apply.js" },
142+
"kind": "calls",
143+
"mode": "dynamic",
144+
"notes": "greet.call(user, 'Hi') — .call() extracts greet as the callee"
145+
},
146+
{
147+
"source": { "name": "runApply", "file": "bind-call-apply.js" },
148+
"target": { "name": "greet", "file": "bind-call-apply.js" },
149+
"kind": "calls",
150+
"mode": "dynamic",
151+
"notes": "greet.apply(user, ['Hey']) — .apply() extracts greet as the callee"
152+
},
132153
{
133154
"source": { "name": "Dog.speak", "file": "inheritance.js" },
134155
"target": { "name": "Animal.speak", "file": "inheritance.js" },

0 commit comments

Comments
 (0)