Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 122 additions & 6 deletions src/domain/graph/builder/stages/build-edges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,7 @@ function buildParamFlowPtsPostPass(
ctx: PipelineContext,
getNodeIdStmt: NodeIdStmt,
allEdgeRows: EdgeRowTuple[],
sharedLookup?: CallNodeLookup,
): void {
// Only process files that actually have paramBindings (avoid useless work).
const filesWithParams = [...ctx.fileSymbols].filter(
Expand All @@ -567,7 +568,7 @@ function buildParamFlowPtsPostPass(
}

const { barrelOnlyFiles, rootDir } = ctx;
const lookup = makeContextLookup(ctx, getNodeIdStmt);
const lookup = sharedLookup ?? makeContextLookup(ctx, getNodeIdStmt);

for (const [relPath, symbols] of filesWithParams) {
if (barrelOnlyFiles.has(relPath)) continue;
Expand Down Expand Up @@ -620,6 +621,94 @@ function buildParamFlowPtsPostPass(
}
}

/**
* bind/alias pts post-pass for the native call-edge path.
*
* The native Rust engine has no knowledge of JS-layer fnRefBindings (e.g.
* `const f = fn.bind(ctx)`), so calls to bind-created aliases are not resolved
* to their original function on the native path. This JS post-pass runs after
* the native edge pass and adds only the fnRefBindings-seeded pts edges that the
* native engine missed.
*
* Uses the same seenByPair dedup guard as buildParamFlowPtsPostPass to avoid
* duplicating edges already emitted by the native engine.
*/
function buildFnRefBindingsPtsPostPass(
ctx: PipelineContext,
getNodeIdStmt: NodeIdStmt,
allEdgeRows: EdgeRowTuple[],
sharedLookup?: CallNodeLookup,
): void {
// Only process files that actually have fnRefBindings.
const filesWithBindings = [...ctx.fileSymbols].filter(
([, symbols]) => symbols.fnRefBindings && symbols.fnRefBindings.length > 0,
);
if (filesWithBindings.length === 0) return;

// Seed seenByPair from the existing rows so we don't duplicate native edges.
const seenByPair = new Set<string>();
for (const [srcId, tgtId] of allEdgeRows) {
seenByPair.add(`${srcId}|${tgtId}`);
}

const { barrelOnlyFiles, rootDir } = ctx;
const lookup = sharedLookup ?? makeContextLookup(ctx, getNodeIdStmt);

for (const [relPath, symbols] of filesWithBindings) {
if (barrelOnlyFiles.has(relPath)) continue;
const fileNodeRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
if (!fileNodeRow) continue;

const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir);
const typeMap: Map<string, TypeMapEntry | string> = symbols.typeMap || new Map();
const ptsMap = buildPointsToMapForFile(symbols, importedNames);
if (!ptsMap) continue;

// Only resolve calls whose name is an lhs in fnRefBindings — the same
// narrowed guard used in buildFileCallEdges case (c).
const fnRefBindingLhs = new Set(symbols.fnRefBindings!.map((b) => b.lhs));

for (const call of symbols.calls) {
if (call.receiver || call.dynamic) continue; // bind aliases are flat-keyed, never dynamic
if (!fnRefBindingLhs.has(call.name)) continue;
if (!ptsMap.has(call.name)) continue;

const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow);

// Only resolve calls that had no direct targets (same guard as buildFileCallEdges).
const { targets } = resolveCallTargets(
lookup,
call,
relPath,
importedNames,
typeMap as Map<string, unknown>,
);
if (targets.length > 0) continue;

for (const alias of resolveViaPointsTo(call.name, ptsMap)) {
const { targets: aliasTargets, importedFrom: aliasFrom } = resolveCallTargets(
lookup,
{ name: alias },
relPath,
importedNames,
typeMap as Map<string, unknown>,
);
for (const t of aliasTargets) {
const edgeKey = `${caller.id}|${t.id}`;
if (t.id !== caller.id && !seenByPair.has(edgeKey)) {
const conf =
computeConfidence(relPath, t.file, aliasFrom ?? null) - PROPAGATION_HOP_PENALTY;
if (conf > 0) {
seenByPair.add(edgeKey);
allEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'points-to']);
}
}
}
}
}
}
}

/**
* Phase 8.5: CHA + RTA post-pass for the native call-edge path.
*
Expand Down Expand Up @@ -889,6 +978,12 @@ function buildFileCallEdges(
// no longer tracked here.
const ptsEdgeRows = new Map<string, number>();

// Pre-compute the set of names that appear as lhs in fnRefBindings so that
// case (c) of the pts gate below only fires for names that are genuine
// bind/alias entries, not for every locally-defined function or import that
// buildPointsToMap seeds with a self-pointing entry.
const fnRefBindingLhs = new Set(symbols.fnRefBindings?.map((b) => b.lhs) ?? []);

for (const call of symbols.calls) {
if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue;

Expand Down Expand Up @@ -950,26 +1045,39 @@ function buildFileCallEdges(
}
}

// Phase 8.3 / 8.3c: points-to fallback for unresolved calls.
// Fires for two cases:
// Phase 8.3 / 8.3c / bind: points-to fallback for unresolved calls.
// Fires for three cases:
// (a) dynamic=true: alias calls emitted by extractCallbackReferenceCalls.
// Looks up `call.name` directly (alias entries are flat-keyed).
// (b) non-dynamic: parameter variable calls (fn() where fn is a param).
// Looks up the scoped key `callerName::call.name` to avoid spurious
// edges from same-named parameters across different functions.
// (c) non-dynamic: module-level alias bindings — `f = fn.bind(ctx)` or
// `const f = handler` — where pts('f') was seeded by fnRefBindings.
// Checked against fnRefBindingLhs (the pre-computed set of lhs names from
// fnRefBindings) rather than the full ptsMap, so case (c) only fires for
// genuine bind/alias entries and never for self-seeded local definitions.
// Confidence is penalised by one hop to reflect the extra indirection.
//
// Note: pts edges are added to ptsEdgeRows (not seenCallEdges) so that a later
// direct call to the same target in the same function body can upgrade confidence
// rather than being silently dropped by the dedup guard.
const scopedPtsKey = caller.callerName != null ? `${caller.callerName}::${call.name}` : null;
const flatPtsKey =
!call.dynamic && fnRefBindingLhs.has(call.name) && ptsMap?.has(call.name) ? call.name : null;
if (
targets.length === 0 &&
!call.receiver &&
ptsMap &&
(call.dynamic || (scopedPtsKey != null && ptsMap.has(scopedPtsKey)))
(call.dynamic || (scopedPtsKey != null && ptsMap.has(scopedPtsKey)) || flatPtsKey != null)
) {
const ptsLookupName = call.dynamic ? call.name : (scopedPtsKey ?? call.name);
const ptsLookupName = call.dynamic
? call.name
: scopedPtsKey != null && ptsMap.has(scopedPtsKey)
? scopedPtsKey
: // flatPtsKey != null is guaranteed by the outer if condition: if neither
// call.dynamic nor scopedPtsKey matched, flatPtsKey != null must be true.
flatPtsKey!;
for (const alias of resolveViaPointsTo(ptsLookupName, ptsMap)) {
// Resolve the concrete alias target. Only `name` is needed here — receiver
// and line are not relevant for alias resolution (we are looking up the
Expand Down Expand Up @@ -1360,12 +1468,20 @@ export async function buildEdges(ctx: PipelineContext): Promise<void> {
(ctx.isFullBuild || ctx.fileSymbols.size > ctx.config.build.smallFilesThreshold);
if (useNativeCallEdges) {
buildCallEdgesNative(ctx, getNodeIdStmt, allEdgeRows, allNodesBefore, native!);
// Build the shared lookup once — both pts post-passes use it, avoiding
// redundant construction of the same context closure.
const sharedLookup = makeContextLookup(ctx, getNodeIdStmt);
// Phase 8.3c post-pass: augment native call edges with parameter-flow pts
// edges. The native Rust engine has no knowledge of paramBindings, so any
// `fn()` call inside a higher-order function would be missed. This JS pass
// runs on top of the native edges and adds only the pts-resolved edges that
// the native engine could not produce.
buildParamFlowPtsPostPass(ctx, getNodeIdStmt, allEdgeRows);
buildParamFlowPtsPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
// bind/alias post-pass: augment native call edges with fnRefBindings-seeded
// pts edges. The native Rust engine has no knowledge of JS fnRefBindings
// (e.g. `const f = fn.bind(ctx)`), so calls to bind-created aliases are
// not resolved to their original function on the native path.
buildFnRefBindingsPtsPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
// Phase 8.5 post-pass: augment native call edges with CHA-resolved dispatch.
// The native Rust engine has no knowledge of the CHA context, so this/self
// calls and interface dispatch are not expanded to concrete implementations.
Expand Down
18 changes: 16 additions & 2 deletions src/extractors/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1454,8 +1454,7 @@ function handleVarDeclaratorTypeMap(

// Phase 8.3: record function-reference bindings before any type-analysis early returns.
// Captures `const fn = handler` (identifier) and `const fn = obj.method` (member_expression).
// call_expression and new_expression are intentionally excluded — those are handled by
// Phase 8.2 callAssignments and the constructor type-map respectively.
// Also handles `const f = fn.bind(ctx)` — bind returns a new function aliasing fn.
if (fnRefBindings && valueN) {
if (valueN.type === 'identifier' && !BUILTIN_GLOBALS.has(valueN.text)) {
fnRefBindings.push({ lhs: nameN.text, rhs: valueN.text });
Expand All @@ -1473,6 +1472,21 @@ function handleVarDeclaratorTypeMap(
) {
fnRefBindings.push({ lhs: nameN.text, rhs: prop.text, rhsReceiver: obj.text });
}
} else if (valueN.type === 'call_expression') {
// `const f = fn.bind(ctx)` — bind returns a bound copy of fn; track f → fn so
// pts(f) ⊇ pts(fn) and subsequent `f(args)` calls resolve to fn.
// Note: only flat-identifier binds (fn.bind) are tracked here; method-receiver
// binds like `obj.method.bind(ctx)` are not captured (boundFn must be an identifier).
const callFn = valueN.childForFieldName('function');
if (callFn?.type === 'member_expression') {
const bindProp = callFn.childForFieldName('property');
if (bindProp?.text === 'bind') {
const boundFn = callFn.childForFieldName('object');
if (boundFn?.type === 'identifier' && !BUILTIN_GLOBALS.has(boundFn.text)) {
fnRefBindings.push({ lhs: nameN.text, rhs: boundFn.text });
}
}
}
}
}

Expand Down
24 changes: 24 additions & 0 deletions tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Patterns for Function.prototype.bind / .call / .apply resolution.

function greet(greeting) {
return greeting + ' ' + this.name;
}

var user = { name: 'Alice' };

// bind: var f = fn.bind(ctx) — f() should resolve to fn()
var greetUser = greet.bind(user);

export function runBind() {
return greetUser('Hello');
}

// call: fn.call(ctx, args) — resolved as a direct call to fn
export function runCall() {
return greet.call(user, 'Hi');
}

// apply: fn.apply(ctx, argsArray) — resolved as a direct call to fn
export function runApply() {
return greet.apply(user, ['Hey']);
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,27 @@
"mode": "constructor",
"notes": "new UserService() — class instantiation tracked as consumption"
},
{
"source": { "name": "runBind", "file": "bind-call-apply.js" },
"target": { "name": "greet", "file": "bind-call-apply.js" },
"kind": "calls",
"mode": "points-to",
"notes": "greetUser() — greetUser = greet.bind(user), pts tracks greetUser → greet"
},
{
"source": { "name": "runCall", "file": "bind-call-apply.js" },
"target": { "name": "greet", "file": "bind-call-apply.js" },
"kind": "calls",
"mode": "dynamic",
"notes": "greet.call(user, 'Hi') — .call() extracts greet as the callee"
},
{
"source": { "name": "runApply", "file": "bind-call-apply.js" },
"target": { "name": "greet", "file": "bind-call-apply.js" },
"kind": "calls",
"mode": "dynamic",
"notes": "greet.apply(user, ['Hey']) — .apply() extracts greet as the callee"
},
{
"source": { "name": "Dog.speak", "file": "inheritance.js" },
"target": { "name": "Animal.speak", "file": "inheritance.js" },
Expand Down
Loading