Skip to content

Commit 30367bf

Browse files
authored
feat(resolver): resolve .call()/.apply() this-rebinding and add fun fixture (JS) (#1405)
* test(integration): pin prototype-method-resolution test to WASM engine The test was using auto engine (native-preferred), causing it to pick the published npm native binary which predates the prototype-method fixes. WASM correctly extracts Dog.prototype.bark and resolves all call edges. Fixes #1381 * test(integration): add TODO comment for WASM engine pin (#1400) * fix(resolver): qualified callerName mismatch in class-scoped typeMap lookup When a method is called without a receiver inside a class-qualified method (e.g. `IsValidEmail()` inside `Validators.ValidateUser`), both the WASM and native engines now try the class-qualified name as a fallback. Root cause: the same-class method lookup in `resolveByMethodOrGlobal` was gated on `call.receiver && callerName`, which excluded no-receiver calls. Static sibling calls in C#/Java (e.g. `IsValidEmail()` inside a static class) have no receiver — the guard prevented the `Validators.IsValidEmail` lookup. Fixes: - WASM (call-resolver.ts): `if (call.receiver && callerName)` → `if (callerName)` - Native (edge_builder.rs): moves class-scoped exact lookup outside the `call.receiver.is_some()` guard; suffix scan remains gated on receiver-present to avoid false positives on global function calls inside class methods. Also fixes a latent CHA re-classification bug exposed by this change: the Rust orchestrator classifies roles before the CHA post-pass, so the global fan-out median was computed from pre-CHA edges. After CHA added edges, the median shifted but Validators.cs (not directly connected to CHA-affected files) was excluded from the incremental re-classification, leaving stale roles. Fixed by switching the post-CHA re-classification from incremental to full. C# same-file recall: 0/2 → 2/2 (100%). Overall C# recall: 73.9% → 82.6% (19/23 expected edges). Remaining gap: receiver-typed (0/4) tracked in #1402. * feat(resolver): resolve .call()/.apply() this-rebinding and add fun fixture (JS) - add fun Jelly micro-test fixture (fun.js + expected-edges.json) with correct <root> source names; scores 4/4 named edges (baz/baz2/baz3/baz4 → bar) - add ThisCallBinding type: extracts fn.call(namedCtx,...)/fn.apply(namedCtx,...) and seeds fn::this → namedCtx in the PTS map; when this() is called inside fn, the scoped key lookup resolves to namedCtx - add extractThisCallsWalk: captures this(args) call expressions (where this is the callee, not a receiver) so they participate in PTS resolution - fix extractCallbackReferenceCalls: skip identifier args for .call()/.apply()/.bind() invocations; those are the this-context and function arguments flowing into the delegated function, not callbacks for the current scope (was producing FPs) - add buildThisCallBindingsPtsPostPass: native-engine post-pass that resolves this() calls the same way as WASM, mirroring buildFnRefBindingsPtsPostPass - update JS benchmark fixture: add invoker.call(handler, 10) test case and two new expected edges (runCallThis→invoker dynamic, invoker→handler points-to); JS precision holds at 100%, total expected now 37 Closes #1380 * docs: document O(all_nodes) cost of full classifyNodeRoles after CHA pass * perf(extractor): merge this-call walks into existing traversals to reduce O(n) passes In extractSymbolsQuery, replace two separate extractThisCallsWalk + extractThisCallBindingsWalk full-tree traversals with a single combined extractThisCallAndBindingsWalk pass, halving the per-file traversal cost for the query (WASM) path. In extractSymbolsWalk, inline the this() call detection and .call()/.apply() binding extraction into handleCallExpr, which is already called during the main walkJavaScriptNode traversal, eliminating two extra O(n) passes from the walk path entirely.
1 parent 6f7189e commit 30367bf

7 files changed

Lines changed: 248 additions & 3 deletions

File tree

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

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
ClassRelation,
1818
Definition,
1919
ExtractorOutput,
20+
FnRefBinding,
2021
Import,
2122
NativeAddon,
2223
NodeRow,
@@ -709,6 +710,78 @@ function buildFnRefBindingsPtsPostPass(
709710
}
710711
}
711712

713+
/**
714+
* this-rebinding post-pass for the native call-edge path.
715+
*
716+
* When `fn.call(namedCtx, ...)` or `fn.apply(namedCtx, ...)` is extracted by the
717+
* WASM layer, `thisCallBindings` records `{ callee: 'fn', thisArg: 'namedCtx' }`.
718+
* The native Rust engine has no knowledge of these bindings, so `this()` calls
719+
* inside `fn` remain unresolved. This JS post-pass adds the missing edges by
720+
* resolving `this()` calls inside each `fn` that has a thisCallBinding.
721+
*/
722+
function buildThisCallBindingsPtsPostPass(
723+
ctx: PipelineContext,
724+
getNodeIdStmt: NodeIdStmt,
725+
allEdgeRows: EdgeRowTuple[],
726+
sharedLookup?: CallNodeLookup,
727+
): void {
728+
const filesWithBindings = [...ctx.fileSymbols].filter(
729+
([, symbols]) => symbols.thisCallBindings && symbols.thisCallBindings.length > 0,
730+
);
731+
if (filesWithBindings.length === 0) return;
732+
733+
const seenByPair = new Set<string>();
734+
for (const [srcId, tgtId] of allEdgeRows) {
735+
seenByPair.add(`${srcId}|${tgtId}`);
736+
}
737+
738+
const { barrelOnlyFiles, rootDir } = ctx;
739+
const lookup = sharedLookup ?? makeContextLookup(ctx, getNodeIdStmt);
740+
741+
for (const [relPath, symbols] of filesWithBindings) {
742+
if (barrelOnlyFiles.has(relPath)) continue;
743+
const fileNodeRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
744+
if (!fileNodeRow) continue;
745+
746+
const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir);
747+
const typeMap: Map<string, TypeMapEntry | string> = symbols.typeMap || new Map();
748+
const ptsMap = buildPointsToMapForFile(symbols, importedNames);
749+
if (!ptsMap) continue;
750+
751+
// Only process calls named 'this' (callee-not-receiver usage)
752+
for (const call of symbols.calls) {
753+
if (call.name !== 'this' || call.receiver) continue;
754+
755+
const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow);
756+
if (caller.callerName == null) continue;
757+
758+
const scopedKey = `${caller.callerName}::this`;
759+
if (!ptsMap.has(scopedKey)) continue;
760+
761+
for (const alias of resolveViaPointsTo(scopedKey, ptsMap)) {
762+
const { targets: aliasTargets, importedFrom: aliasFrom } = resolveCallTargets(
763+
lookup,
764+
{ name: alias },
765+
relPath,
766+
importedNames,
767+
typeMap as Map<string, unknown>,
768+
);
769+
for (const t of aliasTargets) {
770+
const edgeKey = `${caller.id}|${t.id}`;
771+
if (t.id !== caller.id && !seenByPair.has(edgeKey)) {
772+
const conf =
773+
computeConfidence(relPath, t.file, aliasFrom ?? null) - PROPAGATION_HOP_PENALTY;
774+
if (conf > 0) {
775+
seenByPair.add(edgeKey);
776+
allEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'points-to']);
777+
}
778+
}
779+
}
780+
}
781+
}
782+
}
783+
}
784+
712785
/**
713786
* Phase 8.3f post-pass for the native call-edge path.
714787
*
@@ -1144,6 +1217,7 @@ function buildPointsToMapForFile(
11441217
symbols: ExtractorOutput,
11451218
importedNames: Map<string, string>,
11461219
): PointsToMap | null {
1220+
const hasThisCallBindings = !!symbols.thisCallBindings?.length;
11471221
if (
11481222
!symbols.fnRefBindings?.length &&
11491223
!symbols.paramBindings?.length &&
@@ -1152,7 +1226,8 @@ function buildPointsToMapForFile(
11521226
!symbols.forOfBindings?.length &&
11531227
!symbols.arrayCallbackBindings?.length &&
11541228
!symbols.objectRestParamBindings?.length &&
1155-
!symbols.objectPropBindings?.length
1229+
!symbols.objectPropBindings?.length &&
1230+
!hasThisCallBindings
11561231
)
11571232
return null;
11581233
const defNames = new Set(
@@ -1161,8 +1236,21 @@ function buildPointsToMapForFile(
11611236
.map((d) => d.name),
11621237
);
11631238
const definitionParams = buildDefinitionParamsMap(symbols.definitions);
1239+
1240+
// Convert thisCallBindings into scoped fnRefBindings: `fn::this → namedCtx`.
1241+
// The scoped key `fn::this` is looked up when `this()` calls are resolved inside
1242+
// function `fn` — caller.callerName='fn', call.name='this' → scopedPtsKey='fn::this'.
1243+
let allFnRefBindings: readonly FnRefBinding[] = symbols.fnRefBindings ?? [];
1244+
if (hasThisCallBindings) {
1245+
const extra: FnRefBinding[] = (symbols.thisCallBindings ?? []).map((b) => ({
1246+
lhs: `${b.callee}::this`,
1247+
rhs: b.thisArg,
1248+
}));
1249+
allFnRefBindings = [...allFnRefBindings, ...extra];
1250+
}
1251+
11641252
return buildPointsToMap(
1165-
symbols.fnRefBindings ?? [],
1253+
allFnRefBindings,
11661254
defNames,
11671255
importedNames,
11681256
symbols.paramBindings,
@@ -1816,6 +1904,9 @@ export async function buildEdges(ctx: PipelineContext): Promise<void> {
18161904
// (e.g. `const f = fn.bind(ctx)`), so calls to bind-created aliases are
18171905
// not resolved to their original function on the native path.
18181906
buildFnRefBindingsPtsPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
1907+
// this-rebinding post-pass: resolve `this()` calls inside functions that
1908+
// were invoked via `.call(namedCtx, ...)` / `.apply(namedCtx, ...)`.
1909+
buildThisCallBindingsPtsPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
18191910
// Phase 8.3f post-pass: augment native call edges with object rest-param
18201911
// receiver resolution — typeMap[restName] → argName → typeMap[argName.method].
18211912
buildObjectRestParamPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);

src/domain/graph/builder/stages/native-orchestrator.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1617,6 +1617,11 @@ export async function tryNativeOrchestrator(
16171617
// pre-CHA might be near the median, but post-CHA the median is higher, changing
16181618
// its role from utility → core.) Using an incremental pass with a stale median
16191619
// cache would produce incorrect roles outside the CHA-affected file set.
1620+
//
1621+
// Performance: classifyNodeRoles is O(all_nodes). For most repos this is sub-100ms;
1622+
// on very large codebases (100k+ nodes) it may add a few hundred ms per build.
1623+
// If this becomes a bottleneck, consider a two-pass strategy: incremental first
1624+
// (fast, slightly inaccurate), then full only when the median shifts by >N%.
16201625
const chaEdgeCount = runPostNativeCha(ctx.db as unknown as BetterSqlite3Database);
16211626
if (chaEdgeCount > 0) {
16221627
try {

src/extractors/javascript.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
ParamBinding,
1717
SpreadArgBinding,
1818
SubDeclaration,
19+
ThisCallBinding,
1920
TreeSitterNode,
2021
TreeSitterQuery,
2122
TreeSitterTree,
@@ -337,6 +338,7 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr
337338
const arrayCallbackBindings: ArrayCallbackBinding[] = [];
338339
const objectRestParamBindings: ObjectRestParamBinding[] = [];
339340
const objectPropBindings: ObjectPropBinding[] = [];
341+
const thisCallBindings: ThisCallBinding[] = [];
340342

341343
const matches = query.matches(tree.rootNode);
342344

@@ -393,6 +395,9 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr
393395
const definePropertyReceivers: Map<string, string> = new Map();
394396
extractDefinePropertyReceiversWalk(tree.rootNode, definePropertyReceivers);
395397

398+
// this() calls + this-call bindings in a single pass (fn.call(ctx,...) / fn.apply(ctx,...))
399+
extractThisCallAndBindingsWalk(tree.rootNode, calls, thisCallBindings);
400+
396401
return {
397402
definitions,
398403
calls,
@@ -410,6 +415,7 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr
410415
arrayCallbackBindings,
411416
objectRestParamBindings,
412417
objectPropBindings,
418+
thisCallBindings,
413419
newExpressions,
414420
...(definePropertyReceivers.size > 0 ? { definePropertyReceivers } : {}),
415421
};
@@ -684,6 +690,7 @@ function extractSymbolsWalk(tree: TreeSitterTree): ExtractorOutput {
684690
arrayCallbackBindings: [],
685691
objectRestParamBindings: [],
686692
objectPropBindings: [],
693+
thisCallBindings: [],
687694
};
688695

689696
walkJavaScriptNode(tree.rootNode, ctx);
@@ -1124,11 +1131,44 @@ function handleCallExpr(node: TreeSitterNode, ctx: ExtractorOutput): void {
11241131
if (fn.type === 'import') {
11251132
handleDynamicImportCall(node, ctx.imports);
11261133
} else {
1134+
// this() calls: `this` used as a function (not as a receiver).
1135+
if (fn.type === 'this') {
1136+
ctx.calls.push({ name: 'this', line: nodeStartLine(node) });
1137+
return; // no further processing needed for this()-style calls
1138+
}
11271139
const callInfo = extractCallInfo(fn, node);
11281140
if (callInfo) ctx.calls.push(callInfo);
11291141
if (fn.type === 'member_expression') {
11301142
const cbDef = extractCallbackDefinition(node, fn);
11311143
if (cbDef) ctx.definitions.push(cbDef);
1144+
// this-call bindings: `fn.call(namedCtx, ...)` / `fn.apply(namedCtx, ...)`
1145+
const obj = fn.childForFieldName('object');
1146+
const prop = fn.childForFieldName('property');
1147+
if (
1148+
obj?.type === 'identifier' &&
1149+
prop &&
1150+
(prop.text === 'call' || prop.text === 'apply') &&
1151+
!BUILTIN_GLOBALS.has(obj.text)
1152+
) {
1153+
const args = node.childForFieldName('arguments') || findChild(node, 'arguments');
1154+
if (args) {
1155+
for (let i = 0; i < args.childCount; i++) {
1156+
const child = args.child(i);
1157+
if (!child) continue;
1158+
const t = child.type;
1159+
if (t === '(' || t === ')' || t === ',') continue;
1160+
if (
1161+
t === 'identifier' &&
1162+
!BUILTIN_GLOBALS.has(child.text) &&
1163+
child.text !== 'undefined' &&
1164+
child.text !== 'null'
1165+
) {
1166+
ctx.thisCallBindings!.push({ callee: obj.text, thisArg: child.text });
1167+
}
1168+
break;
1169+
}
1170+
}
1171+
}
11321172
}
11331173
ctx.calls.push(...extractCallbackReferenceCalls(node));
11341174
}
@@ -2834,6 +2874,11 @@ function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] {
28342874
if (!args) return [];
28352875

28362876
const calleeName = extractCalleeName(callNode);
2877+
// .call() / .apply() / .bind() — the first arg is the `this` context (not a callback of
2878+
// the enclosing function) and subsequent args flow into the delegated function's parameters.
2879+
// Emitting them here would produce false-positive edges from the *calling* function.
2880+
// This-rebinding (fn::this → ctx) is handled separately by extractThisCallBindingsWalk.
2881+
if (calleeName === 'call' || calleeName === 'apply' || calleeName === 'bind') return [];
28372882
let memberExprArgsAllowed = calleeName !== null && CALLBACK_ACCEPTING_CALLEES.has(calleeName);
28382883
if (memberExprArgsAllowed && calleeName !== null && HTTP_VERB_CALLEES.has(calleeName)) {
28392884
// HTTP verbs require a string-literal route path to be treated as a
@@ -2864,6 +2909,62 @@ function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] {
28642909
return result;
28652910
}
28662911

2912+
/**
2913+
* Single-pass walk to collect both:
2914+
* - `this(args)` call expressions → `{name: 'this', ...}` entries in `calls`
2915+
* (where `this` is used as a function, not as a receiver)
2916+
* - `fn.call(namedCtx, ...)` / `fn.apply(namedCtx, ...)` bindings →
2917+
* `{ callee: 'fn', thisArg: 'namedCtx' }` entries in `thisCallBindings`
2918+
*
2919+
* Combining both into one traversal halves the AST walk cost compared to
2920+
* running two separate recursive passes.
2921+
*/
2922+
function extractThisCallAndBindingsWalk(
2923+
node: TreeSitterNode,
2924+
calls: Call[],
2925+
thisCallBindings: ThisCallBinding[],
2926+
): void {
2927+
if (node.type === 'call_expression') {
2928+
const fn = node.childForFieldName('function');
2929+
if (fn?.type === 'this') {
2930+
calls.push({ name: 'this', line: nodeStartLine(node) });
2931+
} else if (fn?.type === 'member_expression') {
2932+
const obj = fn.childForFieldName('object');
2933+
const prop = fn.childForFieldName('property');
2934+
if (
2935+
obj?.type === 'identifier' &&
2936+
prop &&
2937+
(prop.text === 'call' || prop.text === 'apply') &&
2938+
!BUILTIN_GLOBALS.has(obj.text)
2939+
) {
2940+
const args = node.childForFieldName('arguments') || findChild(node, 'arguments');
2941+
if (args) {
2942+
for (let i = 0; i < args.childCount; i++) {
2943+
const child = args.child(i);
2944+
if (!child) continue;
2945+
const t = child.type;
2946+
if (t === '(' || t === ')' || t === ',') continue;
2947+
// First real argument: only bind if it's a plain identifier
2948+
if (
2949+
t === 'identifier' &&
2950+
!BUILTIN_GLOBALS.has(child.text) &&
2951+
child.text !== 'undefined' &&
2952+
child.text !== 'null'
2953+
) {
2954+
thisCallBindings.push({ callee: obj.text, thisArg: child.text });
2955+
}
2956+
break;
2957+
}
2958+
}
2959+
}
2960+
}
2961+
}
2962+
for (let i = 0; i < node.childCount; i++) {
2963+
const child = node.child(i);
2964+
if (child) extractThisCallAndBindingsWalk(child, calls, thisCallBindings);
2965+
}
2966+
}
2967+
28672968
function findAnonymousCallback(argsNode: TreeSitterNode): TreeSitterNode | null {
28682969
for (let i = 0; i < argsNode.childCount; i++) {
28692970
const child = argsNode.child(i);

src/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,18 @@ export interface ParamBinding {
564564
argName: string;
565565
}
566566

567+
/**
568+
* A this-context binding recorded when `fn.call(namedCtx, ...)` or
569+
* `fn.apply(namedCtx, ...)` is seen. Seeds `fn::this → namedCtx` in the
570+
* points-to map so that `this()` calls inside `fn` resolve to `namedCtx`.
571+
*/
572+
export interface ThisCallBinding {
573+
/** The function being invoked via .call() or .apply(). */
574+
callee: string;
575+
/** The identifier passed as the `this` context (first argument). */
576+
thisArg: string;
577+
}
578+
567579
/**
568580
* An array-element binding: `const arr = [fn1, fn2]` records each named function
569581
* stored at a specific index. Phase 8.3e: array-element pts tracking.
@@ -673,6 +685,12 @@ export interface ExtractorOutput {
673685
objectRestParamBindings?: ObjectRestParamBinding[];
674686
/** Phase 8.3f: object-property bindings from `const obj = { fn }` patterns. */
675687
objectPropBindings?: ObjectPropBinding[];
688+
/**
689+
* This-context bindings from `fn.call(namedCtx, ...)` / `fn.apply(namedCtx, ...)`.
690+
* Seeds `fn::this → namedCtx` in the points-to map so that `this()` calls inside
691+
* `fn` resolve to `namedCtx` when `fn` is invoked via `.call()`/`.apply()`.
692+
*/
693+
thisCallBindings?: ThisCallBinding[];
676694
/**
677695
* Phase 8.5 (RTA): constructor names from all `new X()` expressions in the file,
678696
* including unassigned ones (e.g. `doSomething(new Foo())`). Used to build the

tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,17 @@ export function runCall() {
2222
export function runApply() {
2323
return greet.apply(user, ['Hey']);
2424
}
25+
26+
// call with this as a callable: fn.call(namedFn, args) where namedFn is the 'this' context.
27+
// Inside invoker, calling this(x) should resolve to the function passed as ctx.
28+
function invoker(x) {
29+
return this(x);
30+
}
31+
32+
function handler(n) {
33+
return n * 2;
34+
}
35+
36+
export function runCallThis() {
37+
return invoker.call(handler, 10);
38+
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,20 @@
192192
"mode": "dynamic",
193193
"notes": "greet.apply(user, ['Hey']) — .apply() extracts greet as the callee"
194194
},
195+
{
196+
"source": { "name": "runCallThis", "file": "bind-call-apply.js" },
197+
"target": { "name": "invoker", "file": "bind-call-apply.js" },
198+
"kind": "calls",
199+
"mode": "dynamic",
200+
"notes": "invoker.call(handler, 10) — .call() extracts invoker as the callee"
201+
},
202+
{
203+
"source": { "name": "invoker", "file": "bind-call-apply.js" },
204+
"target": { "name": "handler", "file": "bind-call-apply.js" },
205+
"kind": "calls",
206+
"mode": "points-to",
207+
"notes": "invoker.call(handler, 10) — this-rebinding: this() inside invoker resolves to handler"
208+
},
195209
{
196210
"source": { "name": "Dog.speak", "file": "inheritance.js" },
197211
"target": { "name": "Animal.speak", "file": "inheritance.js" },

tests/benchmarks/resolution/resolution-benchmark.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,9 @@ const THRESHOLDS: Record<string, { precision: number; recall: number }> = {
125125
// adds bind/call/apply resolution (3 new edges in bind-call-apply.js), total expected now 33.
126126
// Phase 8.3f adds Object.defineProperty accessor this-dispatch (#1335): getter→baz in
127127
// define-property.js and accessorGetter→accessorTarget.accessMethod in define-property-accessor.js,
128-
// total expected now 35.
128+
// total expected now 35. call/apply this-rebinding adds 2 edges (runCallThis→invoker,
129+
// invoker→handler) and removes the false-positive from handler being extracted as a callback
130+
// arg of .call() — total expected now 37.
129131
javascript: { precision: 1.0, recall: 0.9 },
130132
// pts-javascript: hand-authored points-to JS fixture (for-of, Set, Array.from, spread) — patterns
131133
// too broad for the main JS fixture. Patterns split per file to prevent intra-fixture FPs.

0 commit comments

Comments
 (0)