Skip to content

Commit 519173f

Browse files
committed
fix(extractor): narrow .call/.apply/.bind skip in extractCallbackReferenceCalls
For .call()/.apply(): the first argument is the this-context and is now skipped; subsequent identifier arguments are emitted as genuine callbacks. e.g. forEach.call(arr, handler) → emits handler, not arr. For .bind(): all arguments are absorbed into the partially-applied function and none are direct callbacks — return [] immediately. Closes #1406
1 parent 784951d commit 519173f

5 files changed

Lines changed: 68 additions & 1 deletion

File tree

src/extractors/javascript.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2726,12 +2726,24 @@ function firstArgIsStringLiteral(argsNode: TreeSitterNode): boolean {
27262726
* member-expr args are only emitted when the first argument is a string
27272727
* literal route path — matching Express/router shape and skipping
27282728
* `cache.get(user.id)`-style calls.
2729+
*
2730+
* `.call()`/`.apply()`: the first argument is the `this` context (not a
2731+
* callback). Subsequent identifier arguments are genuine callbacks of the
2732+
* enclosing scope — e.g. `Array.prototype.forEach.call(arr, handler)` emits
2733+
* `handler`. `.bind()` returns a new partially-applied function; all
2734+
* arguments are absorbed and none are direct callbacks.
27292735
*/
27302736
function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] {
27312737
const args = callNode.childForFieldName('arguments') || findChild(callNode, 'arguments');
27322738
if (!args) return [];
27332739

27342740
const calleeName = extractCalleeName(callNode);
2741+
2742+
// .bind() absorbs all arguments into a partially-applied function — no direct callbacks.
2743+
if (calleeName === 'bind') return [];
2744+
2745+
const skipFirstArg = calleeName === 'call' || calleeName === 'apply';
2746+
27352747
let memberExprArgsAllowed = calleeName !== null && CALLBACK_ACCEPTING_CALLEES.has(calleeName);
27362748
if (memberExprArgsAllowed && calleeName !== null && HTTP_VERB_CALLEES.has(calleeName)) {
27372749
// HTTP verbs require a string-literal route path to be treated as a
@@ -2742,10 +2754,18 @@ function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] {
27422754

27432755
const result: Call[] = [];
27442756
const callLine = nodeStartLine(callNode);
2757+
let realArgIndex = 0;
27452758

27462759
for (let i = 0; i < args.childCount; i++) {
27472760
const child = args.child(i);
27482761
if (!child) continue;
2762+
if (child.type === '(' || child.type === ')' || child.type === ',') continue;
2763+
2764+
if (skipFirstArg && realArgIndex === 0) {
2765+
realArgIndex++;
2766+
continue;
2767+
}
2768+
realArgIndex++;
27492769

27502770
if (child.type === 'identifier') {
27512771
result.push({ name: child.text, line: callLine, dynamic: true });

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,13 @@ export function runCall() {
2222
export function runApply() {
2323
return greet.apply(user, ['Hey']);
2424
}
25+
26+
// .call() with a callback identifier after the this-context
27+
function processItem(item) {
28+
return item;
29+
}
30+
var items = [1, 2, 3];
31+
32+
export function runCallWithCallback() {
33+
items.forEach.call(items, processItem);
34+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,13 @@
192192
"mode": "dynamic",
193193
"notes": "greet.apply(user, ['Hey']) — .apply() extracts greet as the callee"
194194
},
195+
{
196+
"source": { "name": "runCallWithCallback", "file": "bind-call-apply.js" },
197+
"target": { "name": "processItem", "file": "bind-call-apply.js" },
198+
"kind": "calls",
199+
"mode": "dynamic",
200+
"notes": "forEach.call(items, processItem) — processItem is a genuine callback after the this-context; items is NOT emitted as a call"
201+
},
195202
{
196203
"source": { "name": "Dog.speak", "file": "inheritance.js" },
197204
"target": { "name": "Animal.speak", "file": "inheritance.js" },

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ const THRESHOLDS: Record<string, { precision: number; recall: number }> = {
121121
// adds bind/call/apply resolution (3 new edges in bind-call-apply.js), total expected now 33.
122122
// Phase 8.3f adds Object.defineProperty accessor this-dispatch (#1335): getter→baz in
123123
// define-property.js and accessorGetter→accessorTarget.accessMethod in define-property-accessor.js,
124-
// total expected now 35.
124+
// total expected now 35. #1406 adds runCallWithCallback→processItem (forEach.call callback),
125+
// total expected now 36.
125126
javascript: { precision: 1.0, recall: 0.9 },
126127
// TS 0.72: Phase 8.3e adds this.method() same-class resolution (Shape.describe → Shape.area),
127128
// lifting recall from 69.4% to 72.2%. Remaining gap (interface-dispatch, CHA) is tracked

tests/parsers/javascript.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,35 @@ describe('JavaScript parser', () => {
800800
expect(def.line).toBe(2);
801801
expect(def.endLine).toBe(4);
802802
});
803+
804+
// .call/.apply/.bind narrowing (#1406)
805+
it('emits identifier args after the this-context for .call()', () => {
806+
const symbols = parseJS(`Array.prototype.forEach.call(collection, handler);`);
807+
expect(symbols.calls).toContainEqual(
808+
expect.objectContaining({ name: 'handler', dynamic: true }),
809+
);
810+
expect(symbols.calls).not.toContainEqual(expect.objectContaining({ name: 'collection' }));
811+
});
812+
813+
it('emits identifier args after the this-context for .apply()', () => {
814+
const symbols = parseJS(`fn.apply(ctx, handler);`);
815+
expect(symbols.calls).toContainEqual(
816+
expect.objectContaining({ name: 'handler', dynamic: true }),
817+
);
818+
expect(symbols.calls).not.toContainEqual(expect.objectContaining({ name: 'ctx' }));
819+
});
820+
821+
it('emits nothing for .call() with only the this-context arg', () => {
822+
const symbols = parseJS(`fn.call(ctx);`);
823+
const callbackCalls = symbols.calls.filter((c) => c.name === 'ctx');
824+
expect(callbackCalls).toHaveLength(0);
825+
});
826+
827+
it('emits nothing for .bind() — all args are absorbed into the partial application', () => {
828+
const symbols = parseJS(`Promise.resolve.bind(null, transform);`);
829+
expect(symbols.calls).not.toContainEqual(expect.objectContaining({ name: 'transform' }));
830+
expect(symbols.calls).not.toContainEqual(expect.objectContaining({ name: 'null' }));
831+
});
803832
});
804833

805834
describe('Phase 8.3f: object-destructuring rest parameter binding extraction', () => {

0 commit comments

Comments
 (0)